= 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';
-
- Your towers save with a private recovery token in Settings, then Account. Use it to
- restore your workspace on another device.
-
-
-
- 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);
}