diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index 10fe47b..07f12f1 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -13,7 +13,16 @@ import { getColorOfTag } from '../../utils/color'; imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
+
`, styles: ` @import '../../../library/main'; @@ -30,7 +39,12 @@ import { getColorOfTag } from '../../utils/color'; cursor: pointer; @media (hover: hover) and (pointer: fine) { - @include gravitate(); + transition: transform $long-animation-time; + + &:hover, + &.hovered { + transform: translateY(4px); + } } } } @@ -39,6 +53,7 @@ import { getColorOfTag } from '../../utils/color'; export class BlockComponent { readonly block = input.required(); readonly baseColor = input.required(); + readonly hovered = input(false); /** Emits when the square is clicked — parent opens the block-edit modal. */ readonly clicked = output(); diff --git a/frontend/src/app/components/tower/tower.component.ts b/frontend/src/app/components/tower/tower.component.ts index 9581e4f..a9a3fdb 100644 --- a/frontend/src/app/components/tower/tower.component.ts +++ b/frontend/src/app/components/tower/tower.component.ts @@ -70,6 +70,7 @@ export function selectVisibleStyledBlocks( styled: StyledBlock[], visibleLimit: number, enteringInRangeId: string | null, + prevVisibleIds: ReadonlySet = new Set(), ): { visibleStyled: StyledBlock[]; hiddenCount: number } { // `visibleLimit` is a number of SQUARE slots. A block draws `difficulty` // squares, so we cap by cumulative square cost, not by raw block count. @@ -88,18 +89,19 @@ export function selectVisibleStyledBlocks( } const hiddenCount = Math.max(0, totalSquares(restingBlocks) - totalSquares(shownRestingBlocks)); - const usedSquares = totalSquares(shownRestingBlocks); - const remainingBudget = Math.max(0, normalizedLimit - usedSquares); - const transitioningBlocks = - remainingBudget > 0 - ? fitNewestBySquares( - styled.filter((b) => b._opacity !== '1'), - remainingBudget, - ) - : []; + + // Blocks leaving past the upper date bound (opacity 0, `_anim: 'ascend'`) must + // stay in the render list so their fly-up transition actually plays — even + // when the resting stack already fills the whole square budget. Without this, + // a capped stack (e.g. the example page) destroys the element the instant the + // slider hides it and it just vanishes. Restrict to blocks that were visible a + // moment ago: ones already off-screen have nothing to animate from, so leaving + // them out keeps the rendered set (and the phantom flex slots) bounded. + const exitingBlocks = styled.filter((b) => b._opacity !== '1' && prevVisibleIds.has(b.id)); + const shownIds = new Set([ ...shownRestingBlocks.map((b) => b.id), - ...transitioningBlocks.map((b) => b.id), + ...exitingBlocks.map((b) => b.id), ]); return { @@ -152,7 +154,6 @@ export function selectVisibleStyledBlocks(
} @@ -290,7 +296,7 @@ export function selectVisibleStyledBlocks( --block-stack-height: 0px; --add-block-size: 48px; --add-block-clearance: var(--medium-padding); - --empty-add-block-center-offset: 0px; + --add-block-center-offset: 0px; @include card(); overflow: hidden; @@ -372,23 +378,13 @@ export function selectVisibleStyledBlocks( top: max( 0px, min( - calc(50% - var(--add-block-size) / 2), + calc(50% - var(--add-block-size) / 2 - var(--add-block-center-offset)), calc(100% - var(--block-stack-height) - var(--add-block-clearance) - var(--add-block-size)) ) ); transform: translateX(-50%); } - &.empty img.add-block { - top: max( - 0px, - min( - calc(50% - var(--add-block-size) / 2 - var(--empty-add-block-center-offset)), - calc(100% - var(--add-block-size)) - ) - ); - } - .block-container-container { position: absolute; inset: 0; @@ -544,6 +540,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy { readonly editEntry = signal(null); readonly showSettings = signal(false); readonly hiddenBlockCount = signal(0); + readonly hoveredBlockId = signal(null); private readonly stackZone = viewChild>('stackZone'); private readonly towerRoot = viewChild>('towerRoot'); @@ -656,7 +653,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy { const styles = getComputedStyle(stackZone); const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48); - this.measureEmptyAddBlockOffset(stackZone); + this.measureAddBlockCenterOffset(stackZone); const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15; const clearance = this.parseCssPixels( styles.getPropertyValue('--add-block-clearance'), @@ -669,7 +666,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy { this.maxVisibleBlocks.set(Math.max(0, rows) * BLOCKS_PER_ROW); } - private measureEmptyAddBlockOffset(stackZone: HTMLElement): void { + private measureAddBlockCenterOffset(stackZone: HTMLElement): void { const towerRoot = this.towerRoot()?.nativeElement; if (!towerRoot) return; @@ -678,7 +675,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy { const stackCenter = stackRect.top + stackRect.height / 2; const towerCenter = towerRect.top + towerRect.height / 2; const offset = Math.max(0, stackCenter - towerCenter); - stackZone.style.setProperty('--empty-add-block-center-offset', `${offset}px`); + stackZone.style.setProperty('--add-block-center-offset', `${offset}px`); } private parseCssPixels(value: string, fallback: number): number { @@ -737,6 +734,9 @@ export class TowerComponent implements AfterViewInit, OnDestroy { } 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. + const prevVisibleIds = new Set(this._visibleBlocks().map((b) => b.id)); const visibleLimit = maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks); let visibleStyled = styled; @@ -746,6 +746,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy { styled, visibleLimit, newInRangeId, + prevVisibleIds, )); } this.hiddenBlockCount.set(hiddenCount); @@ -809,9 +810,31 @@ export class TowerComponent implements AfterViewInit, OnDestroy { } onEditBlock(block: Block): void { + this.hoveredBlockId.set(null); this.editEntry.set({ filter: block.is_done ? 'done' : 'pending', activeId: block.id }); } + onBlockPointerEnter(blockId: string): void { + this.hoveredBlockId.set(blockId); + } + + onBlockPointerLeave(blockId: string, event: PointerEvent): void { + if (this.relatedTargetBelongsToBlock(event.relatedTarget, blockId)) return; + if (this.hoveredBlockId() === blockId) this.hoveredBlockId.set(null); + } + + private relatedTargetBelongsToBlock(target: EventTarget | null, blockId: string): boolean { + const towerRoot = this.towerRoot()?.nativeElement; + let element = target instanceof Element ? target : null; + + while (element && element !== towerRoot) { + if (element instanceof HTMLElement && element.dataset['blockId'] === blockId) return true; + element = element.parentElement; + } + + return false; + } + /** Tickbox in the tasks accordion — flip is_done to true without opening the carousel. */ onMarkTaskDone(block: Block): void { this.saveBlock.emit({ diff --git a/frontend/src/app/components/tower/tower.component.vitest.ts b/frontend/src/app/components/tower/tower.component.vitest.ts index 056c7e1..c54cc83 100644 --- a/frontend/src/app/components/tower/tower.component.vitest.ts +++ b/frontend/src/app/components/tower/tower.component.vitest.ts @@ -78,6 +78,40 @@ describe('selectVisibleStyledBlocks', () => { expect(result.hiddenCount).toBe(4); }); + it('keeps a previously-visible block that is now flying out, even on a full stack', () => { + // Budget is full with three resting blocks; a fourth block has just left the + // range (opacity 0 → ascending). It was visible a moment ago, so it must stay + // rendered so its fly-up transition can play instead of vanishing instantly. + const styled = [ + block('old-1'), + block('old-2'), + block('old-3'), + block('leaving', '0'), + ]; + const prevVisible = new Set(['old-1', 'old-2', 'old-3', 'leaving']); + + const result = selectVisibleStyledBlocks(styled, 3, null, prevVisible); + + expect(result.visibleStyled.map((b) => b.id)).toContain('leaving'); + expect(result.hiddenCount).toBe(0); + }); + + it('does not resurrect an off-screen block that leaves the range', () => { + // 'hidden-leaving' was never rendered (not in prevVisible) — nothing to + // animate from, so keep it out and avoid an unbounded phantom render set. + const styled = [ + block('old-1'), + block('old-2'), + block('old-3'), + block('hidden-leaving', '0'), + ]; + const prevVisible = new Set(['old-1', 'old-2', 'old-3']); + + const result = selectVisibleStyledBlocks(styled, 3, null, prevVisible); + + expect(result.visibleStyled.map((b) => b.id)).not.toContain('hidden-leaving'); + }); + it('counts hidden squares when reserving the entering block', () => { const styled = [ block('newly-done', '1', 3), diff --git a/frontend/src/app/components/welcome/welcome.component.ts b/frontend/src/app/components/welcome/welcome.component.ts index be26d3c..eefa8de 100644 --- a/frontend/src/app/components/welcome/welcome.component.ts +++ b/frontend/src/app/components/welcome/welcome.component.ts @@ -89,15 +89,6 @@ import { A11yModule } from '@angular/cdk/a11y';
- - -

- Start empty if you know what to track, or load sample towers you can edit or delete. -

-
@@ -240,15 +231,16 @@ import { A11yModule } from '@angular/cdk/a11y'; } .preview-stack { - display: grid; - grid-template-columns: repeat(3, 1fr); - align-content: end; + display: flex; + flex-flow: row wrap-reverse; + align-content: flex-start; gap: 2px; min-height: 86px; margin-top: auto; span { display: block; + flex: 0 0 calc((100% - 4px) / 3); aspect-ratio: 1; border-radius: 2px; background: var(--block-color); @@ -349,9 +341,7 @@ import { A11yModule } from '@angular/cdk/a11y'; line-height: inherit; } - .basic__text, - .account-note, - .choice-note { + .basic__text { font-family: inherit; font-weight: inherit; color: rgba($text-color, 0.82); @@ -359,16 +349,6 @@ import { A11yModule } from '@angular/cdk/a11y'; line-height: inherit; } - .account-note, - .choice-note { - max-width: 54ch; - margin-inline: auto; - } - - .choice-note { - text-align: center; - } - .actions { display: flex; justify-content: space-around; diff --git a/frontend/src/app/services/store.service.ts b/frontend/src/app/services/store.service.ts index ad5b41d..fd35146 100644 --- a/frontend/src/app/services/store.service.ts +++ b/frontend/src/app/services/store.service.ts @@ -69,7 +69,7 @@ interface ExampleDonePattern { desc: (sequence: number) => string; } -const EXAMPLE_DONE_BLOCKS_PER_TOWER = 120; +const EXAMPLE_DONE_BLOCKS_PER_TOWER = 12; @Injectable({ providedIn: 'root' }) export class StoreService implements OnDestroy { @@ -333,11 +333,11 @@ export class StoreService implements OnDestroy { pages.map((p) => p.id === pageId ? { - ...p, - towers: p.towers.map((t) => - t.id === towerId ? { ...t, blocks: [...t.blocks, block] } : t, - ), - } + ...p, + towers: p.towers.map((t) => + t.id === towerId ? { ...t, blocks: [...t.blocks, block] } : t, + ), + } : p, ), ); @@ -356,16 +356,16 @@ export class StoreService implements OnDestroy { pages.map((p) => p.id === pageId ? { - ...p, - towers: p.towers.map((t) => - t.id === towerId - ? { - ...t, - blocks: this.patchBlockList(t.blocks, blockId, patch).blocks, - } - : t, - ), - } + ...p, + towers: p.towers.map((t) => + t.id === towerId + ? { + ...t, + blocks: this.patchBlockList(t.blocks, blockId, patch).blocks, + } + : t, + ), + } : p, ), ); @@ -377,13 +377,13 @@ export class StoreService implements OnDestroy { pages.map((p) => p.id === pageId ? { - ...p, - towers: p.towers.map((t) => - t.id === towerId - ? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) } - : t, - ), - } + ...p, + towers: p.towers.map((t) => + t.id === towerId + ? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) } + : t, + ), + } : p, ), ); @@ -396,21 +396,21 @@ export class StoreService implements OnDestroy { pages.map((p) => p.id === pageId ? { - ...p, - towers: p.towers.map((t) => - t.id === towerId - ? (() => { - const block = t.blocks.find((b) => b.id === blockId); - if (!block) return t; - const result = this.patchBlockList(t.blocks, blockId, { - is_done: !block.is_done, - }); - becameDone = result.becameDone; - return { ...t, blocks: result.blocks }; - })() - : t, - ), - } + ...p, + towers: p.towers.map((t) => + t.id === towerId + ? (() => { + const block = t.blocks.find((b) => b.id === blockId); + if (!block) return t; + const result = this.patchBlockList(t.blocks, blockId, { + is_done: !block.is_done, + }); + becameDone = result.becameDone; + return { ...t, blocks: result.blocks }; + })() + : t, + ), + } : p, ), ); diff --git a/frontend/src/app/services/store.service.vitest.ts b/frontend/src/app/services/store.service.vitest.ts index 3081883..273841a 100644 --- a/frontend/src/app/services/store.service.vitest.ts +++ b/frontend/src/app/services/store.service.vitest.ts @@ -280,7 +280,7 @@ describe('StoreService', () => { expect(blocks[2].created_at).toBe(100); }); - it('loads welcome example data with hundreds of completed squares', () => { + it('loads welcome example data with a stack of completed squares per tower', () => { const api = makeMockApi(); const store = configure(api); @@ -292,13 +292,13 @@ describe('StoreService', () => { expect(page.towers).toHaveLength(3); const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done)); - expect(doneBlocks.length).toBeGreaterThanOrEqual(300); + expect(doneBlocks.length).toBeGreaterThanOrEqual(90); expect(new Set(page.towers.flatMap((tower) => tower.blocks.map((b) => b.difficulty))).size) .toBeGreaterThan(1); for (const tower of page.towers) { const doneDates = tower.blocks.filter((b) => b.is_done).map((b) => b.created_at); - expect(doneDates.length).toBeGreaterThanOrEqual(100); + expect(doneDates.length).toBeGreaterThanOrEqual(30); expect(doneDates).toEqual([...doneDates].sort((a, b) => a - b)); expect(new Set(tower.blocks.map((b) => b.difficulty)).size).toBeGreaterThan(1); }