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.
|
||||
|
||||
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)
|
||||
|
||||
|
|
|
|||
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.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);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit
|
|||
@for (b of blocks(); track b.id; let i = $index) {
|
||||
<div
|
||||
class="card"
|
||||
[attr.data-block-id]="b.id"
|
||||
[class.active]="activeIdx() === i + 1"
|
||||
[class.near-active]="activeIdx() === i || activeIdx() === i + 2"
|
||||
role="button"
|
||||
|
|
@ -589,6 +590,8 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
readonly blocks = input.required<Block[]>();
|
||||
readonly activeBlockId = input<string | null>(null);
|
||||
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>();
|
||||
/** Default for `is_done` on the create card. */
|
||||
readonly defaultDone = input<boolean>(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 <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.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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<lt-select-add
|
||||
[options]="pageNames()"
|
||||
[selectedIndex]="selectedPageIndex()"
|
||||
[compact]="true"
|
||||
placeholder="Add a new page…"
|
||||
(selectionChange)="onSelectPage($event)"
|
||||
(add)="onAddPage($event)"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
class="container"
|
||||
[class.shadow-border]="onlyShadowBorder()"
|
||||
[class.always-shadow]="alwaysDropShadow()"
|
||||
[class.compact]="compact()"
|
||||
>
|
||||
<div class="background" [class.active]="open()"></div>
|
||||
<div
|
||||
|
|
@ -34,7 +35,7 @@ import {
|
|||
</div>
|
||||
<div class="bottom-container">
|
||||
<div class="bottom" [class.open]="open()">
|
||||
@for (item of resolvedItems(); track item) {
|
||||
@for (item of displayedItems(); track item) {
|
||||
@if (editing()) {
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -370,6 +371,37 @@ import {
|
|||
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 alwaysDropShadow = 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) ─
|
||||
/** @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();
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}): 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<StyledBlock[]>([]);
|
||||
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<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() {
|
||||
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<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(() => {
|
||||
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<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 {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue