Improve UX & fix bugs
All checks were successful
CI / Backend tests (push) Successful in 27s
CI / Frontend lint (push) Successful in 32s
Docker / build-and-push (push) Successful in 40s
CI / Frontend unit tests (push) Successful in 25s
CI / Frontend build (push) Successful in 25s
CI / Playwright e2e (push) Successful in 1m27s

This commit is contained in:
Andras Schmelczer 2026-06-09 08:12:48 +01:00
parent 9a2c8a9483
commit 29de505fe2
10 changed files with 488 additions and 144 deletions

View file

@ -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. 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: `reconcile()` renders done blocks at their **resting** position, then decides what should fall via the pure, unit-tested `decideFalls()` helper. Two guarantees:
1. Sets the new block's `_transform: 'translateY(500%)'` and `_opacity: '0'` (off-screen) - **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.
2. Calls `requestAnimationFrame``requestAnimationFrame` to let the browser paint the initial state - **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.
3. Sets `_anim: 'descend'`, `_transform: 'translateY(0)'`, `_opacity: '1'` — the CSS transition fires
**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) ### Block-edit carousel (modal/block-edit.component)

View file

@ -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 <body>, 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<void> {
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<number> {
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<void> {
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<void> {
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();
}
});

View file

@ -200,9 +200,24 @@ test.describe('Life Towers visuals', () => {
await page.waitForTimeout(1800); await page.waitForTimeout(1800);
await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true }); await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true });
// Open the block-edit carousel for the first tower's first task. // New-tower modal on mobile — the X button must not overlap the name input.
await page.locator('lt-tasks .header').first().click(); 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.waitForTimeout(400);
}
await page.locator('lt-tasks .task-description').first().click(); await page.locator('lt-tasks .task-description').first().click();
await page.waitForSelector('section.modal.active'); await page.waitForSelector('section.modal.active');
await page.waitForTimeout(400); await page.waitForTimeout(400);

View file

@ -62,6 +62,7 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit
@for (b of blocks(); track b.id; let i = $index) { @for (b of blocks(); track b.id; let i = $index) {
<div <div
class="card" class="card"
[attr.data-block-id]="b.id"
[class.active]="activeIdx() === i + 1" [class.active]="activeIdx() === i + 1"
[class.near-active]="activeIdx() === i || activeIdx() === i + 2" [class.near-active]="activeIdx() === i || activeIdx() === i + 2"
role="button" role="button"
@ -589,6 +590,8 @@ export class BlockEditComponent implements AfterViewInit {
readonly blocks = input.required<Block[]>(); readonly blocks = input.required<Block[]>();
readonly activeBlockId = input<string | null>(null); readonly activeBlockId = input<string | null>(null);
readonly tags = input<string[]>([]); readonly tags = input<string[]>([]);
/** Tag to pre-select on the create card (the previous block's tag). */
readonly lastTag = input<string>('');
readonly baseColor = input.required<HslColor>(); readonly baseColor = input.required<HslColor>();
/** Default for `is_done` on the create card. */ /** Default for `is_done` on the create card. */
readonly defaultDone = input<boolean>(true); readonly defaultDone = input<boolean>(true);
@ -633,15 +636,17 @@ export class BlockEditComponent implements AfterViewInit {
untracked(() => this.editedValues.set(m)); 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(() => { effect(() => {
const t = this.tags(); const t = this.tags();
const last = this.lastTag();
untracked(() => { untracked(() => {
const cur = this.newValue(); const cur = this.newValue();
if (!cur.tag) { if (!cur.tag) {
this.newValue.set({ this.newValue.set({
...cur, ...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 { ngAfterViewInit(): void {
// Position scroll on the focused card (or the create card if none).
queueMicrotask(() => {
const blocks = this.blocks(); const blocks = this.blocks();
const focusId = this.activeBlockId(); const focusId = this.activeBlockId();
const focusIdx = focusId const focusIdx = focusId
? Math.max(0, blocks.findIndex((b) => b.id === focusId)) ? Math.max(0, blocks.findIndex((b) => b.id === focusId))
: blocks.length; : blocks.length;
// Position scroll on the focused card (or the create card if none).
//
// Deferred to a rendered FRAME (not a microtask): the modal host is
// reparented to <body> 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.scrollToChild(focusIdx + 1, false);
this.afterFrame(() => this.scrollToChild(focusIdx + 1, false));
}); });
} }
@ -858,27 +873,51 @@ export class BlockEditComponent implements AfterViewInit {
if (!container) return; if (!container) return;
const card = container.children.item(idx) as HTMLElement | null; const card = container.children.item(idx) as HTMLElement | null;
if (!card) return; if (!card) return;
const left = // Use live viewport rects (getBoundingClientRect) rather than offsetLeft:
card.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; // 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: // 'instant' (not 'auto') is required: the carousel sets `scroll-behavior:
// smooth`, and 'auto' defers to that — so the initial open would *animate* // smooth`, and 'auto' (and a bare `scrollLeft =`) defer to it — so the jump
// a scroll across the whole strip to reach the target card (very visible on // would *animate*, and the 150ms `adjustPosition` would then read a
// mobile, where the carousel can be thousands of px wide). Tap-to-navigate // mid-flight position and snap to a neighbour. 'instant' lands in one frame.
// still passes smooth=true for the nice slide between cards. // Tap-to-navigate keeps smooth=true for the nice slide between cards.
container.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' }); container.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' });
this.activeIdx.set(idx); 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 { private adjustPosition(): void {
const container = this.container()?.nativeElement; const container = this.container()?.nativeElement;
if (!container) return; 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 nearestIdx = 1;
let minDist = Infinity; let minDist = Infinity;
// children[0] and children[last] are the placeholders — skip. // children[0] and children[last] are the placeholders — skip.
for (let i = 1; i < container.children.length - 1; i++) { for (let i = 1; i < container.children.length - 1; i++) {
const child = container.children.item(i) as HTMLElement; 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); const d = Math.abs(c - center);
if (d < minDist) { if (d < minDist) {
minDist = d; minDist = d;

View file

@ -57,11 +57,6 @@ export interface TowerSettingsResult {
@include card(); @include card();
width: 66vw; width: 66vw;
max-width: 400px; max-width: 400px;
@media (max-width: $mobile-width) {
width: 88vw;
max-width: 88vw;
padding: var(--medium-padding);
}
box-sizing: border-box; box-sizing: border-box;
padding: var(--large-padding); padding: var(--large-padding);
padding-top: calc(var(--large-padding) + var(--medium-padding)); padding-top: calc(var(--large-padding) + var(--medium-padding));
@ -69,6 +64,13 @@ export interface TowerSettingsResult {
box-shadow: $shadow; box-shadow: $shadow;
display: block; 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 { .exit {
position: absolute; position: absolute;
top: var(--medium-padding); top: var(--medium-padding);

View file

@ -68,13 +68,13 @@
position: relative; position: relative;
// Mobile: fixed-width towers with horizontal scroll (1.5-column rhythm).
@media (max-width: $mobile-width) { @media (max-width: $mobile-width) {
width: auto;
margin-inline: calc(-1 * var(--large-padding));
--mobile-tower-width: calc(66vw - var(--small-padding)); --mobile-tower-width: calc(66vw - var(--small-padding));
--mobile-tower-side-padding: max( --mobile-tower-side-padding: max(var(--medium-padding),
var(--medium-padding), calc((100% - var(--mobile-tower-width)) / 2));
calc((100% - var(--mobile-tower-width)) / 2)
);
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
@ -85,7 +85,7 @@
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: flex-start; justify-content: flex-start;
padding: 0 var(--mobile-tower-side-padding); padding: 0 var(--mobile-tower-side-padding);
max-width: 100%; max-width: none;
gap: var(--medium-padding); gap: var(--medium-padding);
&::-webkit-scrollbar { &::-webkit-scrollbar {

View file

@ -2,6 +2,7 @@
<lt-select-add <lt-select-add
[options]="pageNames()" [options]="pageNames()"
[selectedIndex]="selectedPageIndex()" [selectedIndex]="selectedPageIndex()"
[compact]="true"
placeholder="Add a new page…" placeholder="Add a new page…"
(selectionChange)="onSelectPage($event)" (selectionChange)="onSelectPage($event)"
(add)="onAddPage($event)" (add)="onAddPage($event)"

View file

@ -18,6 +18,7 @@ import {
class="container" class="container"
[class.shadow-border]="onlyShadowBorder()" [class.shadow-border]="onlyShadowBorder()"
[class.always-shadow]="alwaysDropShadow()" [class.always-shadow]="alwaysDropShadow()"
[class.compact]="compact()"
> >
<div class="background" [class.active]="open()"></div> <div class="background" [class.active]="open()"></div>
<div <div
@ -34,7 +35,7 @@ import {
</div> </div>
<div class="bottom-container"> <div class="bottom-container">
<div class="bottom" [class.open]="open()"> <div class="bottom" [class.open]="open()">
@for (item of resolvedItems(); track item) { @for (item of displayedItems(); track item) {
@if (editing()) { @if (editing()) {
<input <input
type="text" type="text"
@ -370,6 +371,37 @@ import {
clip-path: inset(-6px -6px 0 -6px); clip-path: inset(-6px -6px 0 -6px);
} }
} }
// Tighter footprint on mobile (the page selector). The shared defaults
// don't shrink on small screens — the chip stays a ~50px slab and the
// drawer rows sit ~50px apart — so here the chip is slimmed and the
// option list is pulled into a dense, left-aligned menu.
&.compact {
@media (max-width: $mobile-width) {
// Chip (the selected page): snug vertically, but keep the full
// horizontal inset so the name doesn't hug the card edge. The
// default ~50px slab is trimmed to ~35px here.
.top {
padding: var(--small-padding) var(--medium-padding);
min-height: 34px;
}
// Dropdown panel: a tight, left-aligned list. Short rows + a hair of
// gap pull the page names together — the global mobile button rule
// (forms.scss) otherwise pads each to 42px and centers its label.
// Selectors are nested through .bottom-container to out-specify the
// default .bottom / .option mobile rules (which sit at 0,3,0 / 0,4,0).
.bottom-container .bottom {
padding: var(--small-padding) var(--medium-padding);
gap: 2px;
.option {
justify-content: flex-start;
min-height: 30px;
}
}
}
}
} }
`, `,
}) })
@ -383,6 +415,8 @@ export class SelectAddComponent {
readonly placeholder = input<string>('Select…'); readonly placeholder = input<string>('Select…');
readonly alwaysDropShadow = input<boolean>(false); readonly alwaysDropShadow = input<boolean>(false);
readonly onlyShadowBorder = input<boolean>(false); readonly onlyShadowBorder = input<boolean>(false);
/** Trim the chip's padding/min-height on mobile (e.g. the page selector). */
readonly compact = input<boolean>(false);
// ── Legacy compat API (used by pages.component.html until Agent B updates) ─ // ── Legacy compat API (used by pages.component.html until Agent B updates) ─
/** @deprecated Use items instead */ /** @deprecated Use items instead */
@ -412,6 +446,17 @@ export class SelectAddComponent {
return newItems.length ? newItems : oldOptions; 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 { protected resolvedSelected(): string | null {
// New API: string // New API: string
const s = this.selected(); const s = this.selected();

View file

@ -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 { export interface StyledBlock extends Block {
_anim: '' | 'descend' | 'ascend'; _anim: '' | 'descend' | 'ascend';
_transform: string; _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>;
}): string[] {
const { firstRender, animateInitialStack, newIds, restingVisibleIds } = opts;
if (firstRender) {
return animateInitialStack ? [...restingVisibleIds] : [];
}
return newIds.filter((id) => restingVisibleIds.has(id));
}
@Component({ @Component({
selector: 'lt-tower', selector: 'lt-tower',
standalone: true, standalone: true,
@ -209,6 +241,7 @@ export function selectVisibleStyledBlocks(
[blocks]="filteredForEntry()" [blocks]="filteredForEntry()"
[activeBlockId]="entry.activeId" [activeBlockId]="entry.activeId"
[tags]="towerTags()" [tags]="towerTags()"
[lastTag]="lastBlockTag()"
[baseColor]="tower().base_color" [baseColor]="tower().base_color"
[defaultDone]="entry.filter === 'done'" [defaultDone]="entry.filter === 'done'"
(save)="onBlockSave($event)" (save)="onBlockSave($event)"
@ -413,6 +446,10 @@ export function selectVisibleStyledBlocks(
transform: translateY(500%); 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 { .descend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
opacity 500ms 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]; 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 ────────────────────────────────────────────────────── // ── Falling animation ──────────────────────────────────────────────────────
// Same approach as the legacy: detect "exactly one done block was added" // Render done blocks at their RESTING position, then — only for blocks that
// and snap that last block to translateY(500%)/opacity:0, then on next // genuinely arrived this round (a ticked task, a new done block, or a remote
// tick flip it back to translateY(0)/opacity:1 with .descend so the // add over SSE) — play the gravity "fall" imperatively with the Web Animations
// 1.5s gravity transition fires. // 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<StyledBlock[]>([]); private readonly _visibleBlocks = signal<StyledBlock[]>([]);
readonly visibleBlocks = this._visibleBlocks.asReadonly(); readonly visibleBlocks = this._visibleBlocks.asReadonly();
@ -613,8 +661,12 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`; return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`;
}); });
private prevDoneIds: string[] = []; /** Done-block ids present at the previous reconcile — diffed to find arrivals. */
private isFirstRun = true; private prevDoneIds = new Set<string>();
/** 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<Animation>();
constructor() { constructor() {
effect(() => { effect(() => {
@ -648,6 +700,8 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
this.destroyed = true; this.destroyed = true;
for (const id of this.animationFrames) cancelAnimationFrame(id); for (const id of this.animationFrames) cancelAnimationFrame(id);
this.animationFrames.clear(); this.animationFrames.clear();
for (const anim of this.runningAnimations) anim.cancel();
this.runningAnimations.clear();
this.resizeObserver?.disconnect(); this.resizeObserver?.disconnect();
} }
@ -700,111 +754,140 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
maxVisibleBlocks: number | null, maxVisibleBlocks: number | null,
animateInitialStack: boolean, animateInitialStack: boolean,
): void { ): void {
if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) {
this._visibleBlocks.set([]);
this.hiddenBlockCount.set(0);
this.prevDoneIds = allDone.map((b) => b.id);
this.isFirstRun = false;
return;
}
const ids = allDone.map((b) => b.id); const ids = allDone.map((b) => b.id);
const prev = this.prevDoneIds; const idSet = new Set(ids);
const prevSet = new Set(prev); const firstRender = !this.hasRenderedStack;
const newIds = ids.filter((id) => !prevSet.has(id)); const newIds = ids.filter((id) => !this.prevDoneIds.has(id));
const grewByOne =
!this.isFirstRun &&
ids.length === prev.length + 1 &&
newIds.length === 1 &&
prev.every((id) => ids.includes(id)); // no IDs disappeared
// 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[] = []; const styled: StyledBlock[] = [];
for (const b of allDone) { for (const b of allDone) {
if (range && b.created_at < range.from) { if (range && b.created_at < range.from) continue;
// Below min-thumb boundary → drop entirely (instant shuffle, no animation).
continue;
}
if (range && b.created_at > range.to) { 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' });
styled.push({
...b,
_anim: 'ascend',
_transform: 'translateY(500%)',
_opacity: '0',
});
continue; continue;
} }
// In range — descend into position (or appear instantly on first run). styled.push({ ...b, _anim: 'descend', _transform: 'translateY(0)', _opacity: '1' });
styled.push({
...b,
_anim: this.isFirstRun ? '' : 'descend',
_transform: 'translateY(0)',
_opacity: '1',
});
} }
const newInRangeId = grewByOne ? newIds[0] : null; // The example showcase wants its whole stack to fall in on first paint, but
// Captured before any `_visibleBlocks.set` below — lets the cap keep // 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 = idSet;
return;
}
// 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. // currently-shown blocks that are now flying out so their exit animates.
const prevVisibleIds = new Set(this._visibleBlocks().map((b) => b.id)); const prevVisibleIds = new Set(this._visibleBlocks().map((b) => b.id));
const visibleLimit = const { visibleStyled, hiddenCount } = this.capStyled(
maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks);
let visibleStyled = styled;
let hiddenCount = 0;
if (Number.isFinite(visibleLimit)) {
({ visibleStyled, hiddenCount } = selectVisibleStyledBlocks(
styled, styled,
visibleLimit, maxVisibleBlocks,
newInRangeId, reserveId,
prevVisibleIds, prevVisibleIds,
)); );
} this._visibleBlocks.set(visibleStyled);
this.hiddenBlockCount.set(hiddenCount); this.hiddenBlockCount.set(hiddenCount);
if (this.isFirstRun && animateInitialStack) { const restingVisibleIds = new Set(
const initialBlocks = visibleStyled.filter((b) => b._opacity === '1'); visibleStyled.filter((b) => b._opacity === '1').map((b) => b.id),
if (initialBlocks.length > 0) { );
this.startDescendAnimation(visibleStyled, initialBlocks);
this.prevDoneIds = ids; const toFall = decideFalls({ firstRender, animateInitialStack, newIds, restingVisibleIds });
this.isFirstRun = false;
return; this.prevDoneIds = idSet;
} this.hasRenderedStack = true;
if (toFall.length > 0) this.scheduleFall(toFall);
} }
if (grewByOne) { /** Cap the styled stack to the measured square budget (or pass it through
const newId = newIds[0]; * untouched while still unmeasured). */
const newBlock = visibleStyled.find((b) => b.id === newId); private capStyled(
if (newBlock) { styled: StyledBlock[],
// Snap newly-added in-range block to start position, then on the next maxVisibleBlocks: number | null,
// paint flip it back to rest — that's what makes it visibly fall. reserveId: string | null,
this.startDescendAnimation(visibleStyled, [newBlock]); prevVisibleIds: ReadonlySet<string> = new Set(),
this.prevDoneIds = ids; ): { visibleStyled: StyledBlock[]; hiddenCount: number } {
this.isFirstRun = false; if (maxVisibleBlocks === null) {
return; return { visibleStyled: styled, hiddenCount: 0 };
} }
} return selectVisibleStyledBlocks(
// existing fall-through path (no growth, first run, or new block out of range): styled,
this._visibleBlocks.set(visibleStyled); Math.max(0, maxVisibleBlocks),
reserveId,
this.prevDoneIds = ids; prevVisibleIds,
this.isFirstRun = false; );
} }
private startDescendAnimation(visibleStyled: StyledBlock[], blocks: StyledBlock[]): void { /**
for (const block of blocks) { * Play the gravity "fall" on the given blocks' square elements via the Web
block._anim = ''; * Animations API, after they've rendered at their resting position. WAAPI
block._transform = 'translateY(500%)'; * animates the elements in from above on the next frame regardless of
block._opacity = '0'; * change-detection timing, so the fall always fires (the old approach flipped
} * a CSS class across two rAFs and would intermittently drop the animation).
this._visibleBlocks.set(visibleStyled); */
this.requestFrame(() => { private scheduleFall(blockIds: string[], attempt = 0): void {
this.requestFrame(() => { this.requestFrame(() => {
if (this.destroyed) return; if (this.destroyed) return;
this.finishDescendAnimation(blocks); 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<HTMLElement>(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 { private requestFrame(callback: () => void): void {
if (typeof requestAnimationFrame !== 'function') { if (typeof requestAnimationFrame !== 'function') {
callback(); callback();
@ -817,15 +900,6 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
this.animationFrames.add(id); 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 ───────────────────────────────────────────────────────── // ── Event handlers ─────────────────────────────────────────────────────────
onRename(event: Event): void { onRename(event: Event): void {

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { StyledBlock } from './tower.component'; 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 { function block(id: string, opacity = '1', difficulty = 1): StyledBlock {
return { 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', () => { describe('editEntryForNewBlock', () => {
it('opens the create card in the pending task view when tasks are kept open', () => { it('opens the create card in the pending task view when tasks are kept open', () => {
expect(editEntryForNewBlock(true)).toEqual({ filter: 'pending', activeId: null }); expect(editEntryForNewBlock(true)).toEqual({ filter: 'pending', activeId: null });