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
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:
parent
9a2c8a9483
commit
29de505fe2
10 changed files with 488 additions and 144 deletions
11
CLAUDE.md
11
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.
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
112
frontend/e2e/carousel-active-card.spec.ts
Normal file
112
frontend/e2e/carousel-active-card.spec.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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.waitForTimeout(400);
|
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.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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
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).
|
// Position scroll on the focused card (or the create card if none).
|
||||||
queueMicrotask(() => {
|
//
|
||||||
const blocks = this.blocks();
|
// Deferred to a rendered FRAME (not a microtask): the modal host is
|
||||||
const focusId = this.activeBlockId();
|
// reparented to <body> during the same change-detection flush (see
|
||||||
const focusIdx = focusId
|
// modal.component), and on WebKit the carousel's horizontal scroll range
|
||||||
? Math.max(0, blocks.findIndex((b) => b.id === focusId))
|
// isn't established until the next layout after that reparent. A
|
||||||
: blocks.length;
|
// 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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& > * {
|
&>* {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
@ -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,14 +85,14 @@
|
||||||
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 {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > * {
|
&>* {
|
||||||
width: var(--mobile-tower-width) !important;
|
width: var(--mobile-tower-width) !important;
|
||||||
max-width: var(--mobile-tower-width) !important;
|
max-width: var(--mobile-tower-width) !important;
|
||||||
min-width: var(--mobile-tower-width) !important;
|
min-width: var(--mobile-tower-width) !important;
|
||||||
|
|
@ -201,4 +201,4 @@
|
||||||
transform: translateX(-50%) scale(1.1);
|
transform: translateX(-50%) scale(1.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
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._visibleBlocks.set([]);
|
||||||
this.hiddenBlockCount.set(0);
|
this.hiddenBlockCount.set(0);
|
||||||
this.prevDoneIds = allDone.map((b) => b.id);
|
this.prevDoneIds = idSet;
|
||||||
this.isFirstRun = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ids = allDone.map((b) => b.id);
|
// Reserve a visible slot for the newest just-added block so a capped stack
|
||||||
const prev = this.prevDoneIds;
|
// still shows (and can fall) it.
|
||||||
const prevSet = new Set(prev);
|
const reserveId = !firstRender && newIds.length > 0 ? newIds[newIds.length - 1] : null;
|
||||||
const newIds = ids.filter((id) => !prevSet.has(id));
|
// Captured before the `_visibleBlocks.set` below — lets the cap keep
|
||||||
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
|
|
||||||
// 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);
|
styled,
|
||||||
let visibleStyled = styled;
|
maxVisibleBlocks,
|
||||||
let hiddenCount = 0;
|
reserveId,
|
||||||
if (Number.isFinite(visibleLimit)) {
|
prevVisibleIds,
|
||||||
({ visibleStyled, hiddenCount } = selectVisibleStyledBlocks(
|
);
|
||||||
styled,
|
this._visibleBlocks.set(visibleStyled);
|
||||||
visibleLimit,
|
|
||||||
newInRangeId,
|
|
||||||
prevVisibleIds,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
this.isFirstRun = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (grewByOne) {
|
const toFall = decideFalls({ firstRender, animateInitialStack, newIds, restingVisibleIds });
|
||||||
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);
|
|
||||||
|
|
||||||
this.prevDoneIds = ids;
|
this.prevDoneIds = idSet;
|
||||||
this.isFirstRun = false;
|
this.hasRenderedStack = true;
|
||||||
|
|
||||||
|
if (toFall.length > 0) this.scheduleFall(toFall);
|
||||||
}
|
}
|
||||||
|
|
||||||
private startDescendAnimation(visibleStyled: StyledBlock[], blocks: StyledBlock[]): void {
|
/** Cap the styled stack to the measured square budget (or pass it through
|
||||||
for (const block of blocks) {
|
* untouched while still unmeasured). */
|
||||||
block._anim = '';
|
private capStyled(
|
||||||
block._transform = 'translateY(500%)';
|
styled: StyledBlock[],
|
||||||
block._opacity = '0';
|
maxVisibleBlocks: number | null,
|
||||||
|
reserveId: string | null,
|
||||||
|
prevVisibleIds: ReadonlySet<string> = 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(() => {
|
||||||
this.requestFrame(() => {
|
if (this.destroyed) return;
|
||||||
if (this.destroyed) return;
|
const zone = this.stackZone()?.nativeElement;
|
||||||
this.finishDescendAnimation(blocks);
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue