This commit is contained in:
Andras Schmelczer 2026-05-30 14:33:39 +01:00
parent e2a60e71a3
commit 003f38ea60
36 changed files with 1543 additions and 1287 deletions

View file

@ -15,7 +15,6 @@ import {
} from '@angular/core';
import { Block, HslColor } from '../../models';
import { SelectAddComponent } from '../shared/select-add/select-add.component';
import { ToggleComponent } from '../shared/toggle/toggle.component';
import { getColorOfTag } from '../../utils/color';
export interface BlockEditSave {
@ -35,9 +34,13 @@ interface EditedValue {
@Component({
selector: 'lt-block-edit',
standalone: true,
imports: [SelectAddComponent, ToggleComponent],
imports: [SelectAddComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (viewTitle()) {
<h2 class="view-title">{{ viewTitle() }}</h2>
}
<section
#container
class="carousel"
@ -61,7 +64,7 @@ interface EditedValue {
class="block-dot"
[style.background-color]="colorOfTagForBlock(b.id)"
></div>
<h1>{{ formatDate(b.created_at) }}</h1>
<h1>{{ formatDate(b.created_at, true) }}</h1>
</div>
<div class="select-add-container">
@ -83,14 +86,14 @@ interface EditedValue {
(blur)="flushExisting(b.id)"
></textarea>
<div>
<lt-toggle
<label class="done-checkbox">
<input
type="checkbox"
[checked]="editedFor(b.id).is_done"
(checkedChange)="updateDone(b.id, $event)"
offLabel="This task hasn't been finished yet"
onLabel="Goal already accomplished"
(change)="updateDone(b.id, $any($event.target).checked)"
/>
</div>
<span>Already done</span>
</label>
<div class="bottom">
<button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button>
@ -133,14 +136,14 @@ interface EditedValue {
(input)="updateNewDescription($any($event.target).value)"
></textarea>
<div>
<lt-toggle
<label class="done-checkbox">
<input
type="checkbox"
[checked]="newValue().is_done"
(checkedChange)="updateNewDone($event)"
offLabel="This task hasn't been finished yet"
onLabel="Goal already accomplished"
(change)="updateNewDone($any($event.target).checked)"
/>
</div>
<span>Already done</span>
</label>
<div class="bottom">
<button
@ -166,21 +169,50 @@ interface EditedValue {
right: 0;
bottom: 0;
z-index: 10001; // above modal backdrop (10000)
@media (max-height: $min-height) {
align-items: flex-start;
overflow-y: auto;
}
}
.view-title {
position: fixed;
top: var(--large-padding);
left: var(--large-padding);
right: var(--large-padding);
z-index: 10002;
margin: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.carousel {
--title-clearance: calc((var(--large-padding) * 2) + var(--larger-font-size));
width: 100%;
height: 100%;
display: flex;
align-items: center;
box-sizing: border-box;
padding: var(--title-clearance) 0 var(--large-padding);
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
@media (max-width: $mobile-width) {
padding: 0 7.5vw;
padding: var(--title-clearance) var(--medium-padding) var(--medium-padding);
}
@media (max-height: $min-height) {
min-height: max-content;
align-items: flex-start;
padding-top: var(--title-clearance);
padding-bottom: var(--medium-padding);
overflow-y: visible;
}
&::-webkit-scrollbar {
@ -199,10 +231,10 @@ interface EditedValue {
max-width: 400px;
scroll-snap-align: center;
@media (max-width: $mobile-width) {
width: 85vw;
max-width: 85vw;
width: min(88vw, 360px);
max-width: calc(100vw - (2 * var(--medium-padding)));
padding: var(--medium-padding);
margin: calc(var(--medium-padding) / 2);
margin: 0 calc(var(--small-padding) / 2);
opacity: 1 !important;
}
box-sizing: border-box;
@ -256,8 +288,9 @@ interface EditedValue {
width: 60vw;
max-width: 60vw;
@media (max-width: $mobile-width) {
width: 7.5vw;
max-width: 7.5vw;
width: var(--medium-padding);
max-width: var(--medium-padding);
min-width: var(--medium-padding);
}
box-shadow: none;
background: transparent;
@ -266,6 +299,12 @@ interface EditedValue {
.header {
@include center-child();
position: relative;
gap: var(--small-padding);
h1 {
min-width: 0;
overflow-wrap: anywhere;
}
.exit {
position: absolute;
@ -289,6 +328,70 @@ interface EditedValue {
}
}
.done-checkbox {
@include medium-text();
display: flex;
align-items: center;
justify-content: center;
gap: var(--small-padding);
width: max-content;
max-width: 100%;
margin: 0 auto var(--medium-padding);
cursor: pointer;
input[type='checkbox'] {
-webkit-appearance: none;
appearance: none;
@include square(22px);
flex: 0 0 auto;
position: relative;
box-sizing: border-box;
margin: 0;
border: 0;
border-radius: 4px;
background: $light-color;
box-shadow: $shadow-border;
cursor: pointer;
transition: background-color $short-animation-time, box-shadow $long-animation-time, transform $short-animation-time;
&::after {
content: '';
position: absolute;
left: 7px;
top: 3px;
width: 6px;
height: 12px;
border: solid $light-color;
border-width: 0 2px 2px 0;
opacity: 0;
transform: rotate(45deg) scale(0.8);
transition: opacity $short-animation-time, transform $short-animation-time;
}
&:checked {
background: $text-color;
&::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
}
&:hover,
&:focus-visible {
box-shadow: $shadow;
}
&:active {
transform: scale(0.95);
}
}
span {
line-height: 1.3;
}
}
.bottom {
height: 32px;
@media (max-width: $mobile-width) {
@ -306,15 +409,27 @@ interface EditedValue {
}
@media (max-width: $mobile-width) {
lt-select-add, lt-toggle {
lt-select-add,
.done-checkbox {
max-width: 100%;
width: 100%;
}
.bottom {
min-height: 42px;
button {
width: max-content;
max-width: 100%;
min-height: 42px;
}
}
}
}
`,
})
export class BlockEditComponent implements AfterViewInit {
readonly viewTitle = input<string>('');
readonly blocks = input.required<Block[]>();
readonly activeBlockId = input<string | null>(null);
readonly tags = input<string[]>([]);
@ -407,8 +522,16 @@ export class BlockEditComponent implements AfterViewInit {
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
});
formatDate(ts: number): string {
formatDate(ts: number, compact = false): string {
const d = new Date(ts * 1000);
if (compact) {
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// e.g. "May 28, 2026, 14:32"
return d.toLocaleString(undefined, {
year: 'numeric',

View file

@ -31,27 +31,35 @@ import { ModalStateService } from '../../services/modal-state.service';
styles: `
@import '../../../library/main';
/* lt-modal host must not occupy a flex slot in its parent section.modal
is position:fixed, but the host element itself would otherwise take a
slot and push siblings around when it mounts/unmounts.
display: contents removes the host box without affecting descendants. */
/* Keep the component host out of parent flex/grid flow. Parent containers
apply spacing to direct children, so relying on the fixed child alone can
still let <lt-modal> become a layout item when it mounts. */
:host {
display: contents;
position: fixed;
inset: 0;
z-index: 10000;
display: block;
margin: 0 !important;
}
section.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
position: absolute;
inset: 0;
@include center-child();
padding: var(--large-padding);
box-sizing: border-box;
background: $background-gradient;
background: rgba(255, 248, 248, 0.94);
transition: opacity 300ms;
opacity: 1;
overflow-y: auto;
@media (max-width: $mobile-width) {
padding: var(--medium-padding);
}
@media (max-height: $min-height) {
align-items: flex-start;
}
&:not(.active) {
opacity: 0;

View file

@ -72,7 +72,11 @@ export interface PageSettingsResult {
@include card();
width: 66vw;
max-width: 400px;
@media (max-width: $mobile-width) { width: 300px; }
@media (max-width: $mobile-width) {
width: 88vw;
max-width: 88vw;
padding: var(--medium-padding);
}
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
@ -101,6 +105,12 @@ export interface PageSettingsResult {
button {
display: block;
// Stay full-width on mobile, but switch to flex so forms.scss's
// bottom-alignment keeps the underline hugging the label in the 42px
// tap target (plain block centres the text and strands the underline).
@media (max-width: $mobile-width) {
display: flex;
}
}
}
`,

View file

@ -26,7 +26,7 @@ const UUIDV4_RE =
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<h2>Settings</h2>
@if (page()) {
@ -43,19 +43,23 @@ const UUIDV4_RE =
aria-label="Page name"
/>
<lt-toggle
[checked]="hideCreateTowerButton()"
(checkedChange)="onHideCreateTowerButtonChange($event)"
offLabel="Show add-tower button"
onLabel="Hide add-tower button"
/>
<div class="toggle-list">
<lt-toggle
class="setting-toggle"
[checked]="hideCreateTowerButton()"
(checkedChange)="onHideCreateTowerButtonChange($event)"
offLabel="Show add-tower button"
onLabel="Hide add-tower button"
/>
<lt-toggle
[checked]="keepTasksOpen()"
(checkedChange)="onKeepTasksOpenChange($event)"
offLabel="Show tasks collapsed"
onLabel="Keep tasks open"
/>
<lt-toggle
class="setting-toggle"
[checked]="keepTasksOpen()"
(checkedChange)="onKeepTasksOpenChange($event)"
offLabel="Show tasks collapsed"
onLabel="Keep tasks open"
/>
</div>
<button class="danger" type="button" (click)="deletePage.emit()">
Delete this page
@ -68,7 +72,7 @@ const UUIDV4_RE =
<section class="account-section">
<h3>Account</h3>
<p class="hint">Your token (keep it secret it IS your account)</p>
<p class="hint">Copy this token to another device to permanently sync your progress</p>
<div class="token-row">
<input
type="text"
@ -127,17 +131,19 @@ const UUIDV4_RE =
top: var(--medium-padding);
right: var(--medium-padding);
@include exit();
font-size: 0;
}
h2 {
margin: 0 0 var(--large-padding) 0;
padding: 0 36px;
line-height: 1.3;
text-align: center;
}
h3 {
margin: 0 0 var(--medium-padding) 0;
font-size: var(--large-font-size);
font-size: var(--medium-font-size);
line-height: 1.35;
}
section {
@ -155,8 +161,37 @@ const UUIDV4_RE =
margin: var(--large-padding) 0;
}
input[type='text'],
button:not(.exit) {
font-size: var(--medium-font-size);
line-height: 1.35;
}
.toggle-list {
display: flex;
flex-direction: column;
gap: var(--small-padding);
}
lt-toggle.setting-toggle {
--toggle-label-width: 145px;
box-sizing: border-box;
justify-content: center;
width: 100%;
min-height: 52px;
padding: var(--small-padding);
border-radius: var(--border-radius);
background: rgba($text-color, 0.035);
@media (max-width: $mobile-width) {
min-height: 48px;
}
}
.hint {
font-size: var(--small-font-size);
font-size: var(--medium-font-size);
line-height: 1.35;
color: rgba($text-color, 0.7);
margin: 0 0 4px 0;
}

View file

@ -5,7 +5,9 @@ import {
output,
OnInit,
inject,
DestroyRef,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Tower, HslColor } from '../../models';
import { ColorPickerComponent } from '../shared/color-picker/color-picker.component';
@ -39,12 +41,12 @@ export interface TowerSettingsResult {
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
</div>
<button type="submit" [disabled]="form.invalid">
{{ tower() ? 'Save' : 'Create tower' }}
</button>
@if (tower()) {
<!-- Editing an existing tower: changes auto-save, so there's no Save
button only the destructive action remains explicit. -->
<button type="button" (click)="delete.emit()">Delete tower</button>
} @else {
<button type="submit" [disabled]="form.invalid">Create tower</button>
}
</form>
`,
@ -86,14 +88,14 @@ export interface TowerSettingsResult {
border: 0;
}
// Generous gap between the name input and the color picker — the picker
// is a substantial control and crowding it against the title looks busy.
.picker-row {
padding-top: var(--medium-padding);
}
button {
display: block;
// Stay full-width on mobile, but switch to flex so forms.scss's
// bottom-alignment keeps the underline hugging the label in the 42px
// tap target (plain block centres the text and strands the underline).
@media (max-width: $mobile-width) {
display: flex;
}
}
}
`,
@ -105,6 +107,7 @@ export class TowerSettingsComponent implements OnInit {
readonly close = output<void>();
private readonly fb = inject(FormBuilder);
private readonly destroyRef = inject(DestroyRef);
form = this.fb.group({
name: ['', [Validators.required, Validators.maxLength(200)]],
@ -117,17 +120,36 @@ export class TowerSettingsComponent implements OnInit {
if (t) {
this.form.patchValue({ name: t.name });
this.currentColor = { ...t.base_color };
// Edit mode: persist name changes as they happen. Wire this up *after*
// the initial patchValue so seeding the form doesn't fire a save.
this.form.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.autoSave());
}
}
onColorChange(color: HslColor): void {
this.currentColor = color;
// In edit mode the picker is a live control — commit each change.
if (this.tower()) this.autoSave();
}
onSubmit(): void {
// Only the create flow reaches here via its Submit button; edit mode
// auto-saves (and Enter on the single field is a harmless redundant save).
if (this.form.invalid) return;
const v = this.form.value;
this.save.emit({ name: v.name ?? '', base_color: this.currentColor });
this.emitSave();
}
/** Emit a save only when the form is valid (skips e.g. an empty name). */
private autoSave(): void {
if (this.form.invalid) return;
this.emitSave();
}
private emitSave(): void {
this.save.emit({ name: this.form.value.name ?? '', base_color: this.currentColor });
}
}

View file

@ -7,7 +7,7 @@
@for (tower of page().towers; track tower.id) {
<lt-tower
cdkDrag
[cdkDragDisabled]="modalOpen()"
[cdkDragDisabled]="modalOpen() || mobileDragDisabled()"
[tower]="tower"
[dateRange]="dateRange()"
[keepTasksOpen]="page().keep_tasks_open"
@ -48,7 +48,7 @@
@if (showAddTower()) {
<lt-modal title="New Tower" (close)="showAddTower.set(false)">
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" />
<lt-tower-settings [tower]="null" (save)="onAddTower($event)" (close)="showAddTower.set(false)" />
</lt-modal>
}

View file

@ -5,6 +5,7 @@
flex-direction: column;
height: 100%;
min-height: 0;
position: relative; // anchor for absolute-positioned .trash
@include inner-spacing(var(--large-padding));
@ -18,10 +19,12 @@
justify-content: center;
width: 100%;
box-sizing: border-box;
margin-left: auto;
margin-right: auto;
flex: 1 0 auto;
flex: 1 1 auto;
min-height: 0;
transition: box-shadow $short-animation-time;
@ -55,6 +58,7 @@
max-width: 200px;
box-sizing: content-box;
flex: 0 0 auto;
min-height: 0;
&:not(:nth-last-child(1)) {
margin-right: var(--medium-padding);
@ -84,15 +88,22 @@
// without needing !important on every property only the width triples need it
// to beat the per-child-count selectors generated above.
@media (max-width: $mobile-width) {
--mobile-tower-width: calc(66vw - var(--small-padding));
--mobile-tower-side-padding: max(
var(--medium-padding),
calc((100% - var(--mobile-tower-width)) / 2)
);
overflow-x: auto;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x mandatory;
scroll-padding-inline: var(--mobile-tower-side-padding);
flex-wrap: nowrap;
justify-content: flex-start;
// Side padding lets the first and last tower scroll fully into view.
padding: 0 var(--medium-padding);
padding: 0 var(--mobile-tower-side-padding);
max-width: 100%;
gap: var(--medium-padding);
&::-webkit-scrollbar {
display: none;
@ -100,12 +111,16 @@
// Override the @for width-calc rules above.
& > * {
width: calc(66vw - var(--small-padding)) !important;
max-width: calc(66vw - var(--small-padding)) !important;
min-width: calc(66vw - var(--small-padding)) !important;
scroll-snap-align: start;
width: var(--mobile-tower-width) !important;
max-width: var(--mobile-tower-width) !important;
min-width: var(--mobile-tower-width) !important;
scroll-snap-align: center;
flex-shrink: 0;
}
& > *:not(:nth-last-child(1)) {
margin-right: 0;
}
}
}

View file

@ -5,6 +5,7 @@ import {
output,
signal,
inject,
HostListener,
} from '@angular/core';
import { Page } from '../../models';
import { StoreService } from '../../services/store.service';
@ -66,6 +67,7 @@ export class PageComponent {
private readonly modalState = inject(ModalStateService);
/** True while any lt-modal is mounted — used to lock tower drag. */
readonly modalOpen = this.modalState.anyOpen;
readonly mobileDragDisabled = signal(this.isMobileViewport());
readonly showAddTower = signal(false);
readonly isDragging = signal(false);
@ -116,6 +118,18 @@ export class PageComponent {
this.dateRange.set({ from: range.from as number, to: range.to as number });
}
@HostListener('window:resize')
onResize(): void {
this.mobileDragDisabled.set(this.isMobileViewport());
}
private isMobileViewport(): boolean {
return (
typeof window !== 'undefined' &&
window.matchMedia('(max-width: 520px), (pointer: coarse)').matches
);
}
// ── Tower mutations ────────────────────────────────────────────────────────
onAddTower(result: TowerSettingsResult): void {

View file

@ -2,6 +2,7 @@
:host {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
@ -21,7 +22,8 @@
}
.page-container {
flex: 1 0 auto;
flex: 1 1 auto;
min-height: 0;
// Generous breathing room between the page selector dropdown and the
// towers the dropdown can open downward without crowding the towers.
padding-top: var(--large-padding);

View file

@ -61,7 +61,10 @@ const FIXED_L = 0.55;
display: block;
padding: var(--medium-padding);
@include card();
box-shadow: $shadow-border;
border: 1px solid rgba($text-color, 0.14);
box-shadow: inset 0 0 0 1px rgba($light-color, 0.7);
background-color: rgba($text-color, 0.025);
box-sizing: border-box;
}
.picker {
@ -76,35 +79,40 @@ const FIXED_L = 0.55;
grid-template-columns: repeat(12, 1fr);
gap: 6px;
@media (max-width: $mobile-width) {
grid-template-columns: repeat(6, 1fr);
gap: var(--small-padding);
}
.swatch {
all: unset;
cursor: pointer;
aspect-ratio: 1;
border-radius: 4px;
box-shadow: $shadow-border;
box-shadow: 0 0 0 1px rgba($text-color, 0.18);
transition: transform $short-animation-time, box-shadow $long-animation-time;
&:hover,
&:focus-visible {
box-shadow: $shadow;
box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5);
transform: scale(1.1);
}
&.active {
box-shadow: $shadow;
box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5);
transform: scale(1.15);
outline: 2px solid $light-color;
outline-offset: 1px;
}
}
}
.hue-slider {
padding: 8px 0;
input[type='range'] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 12px;
height: 16px;
border-radius: 1000px;
background: linear-gradient(
to right,
@ -119,11 +127,15 @@ const FIXED_L = 0.55;
outline: none;
cursor: pointer;
&:focus-visible {
box-shadow: 0 0 0 3px rgba($text-color, 0.35);
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 24px;
width: 24px;
height: 32px;
width: 32px;
border-radius: 1000px;
background-color: var(--thumb-color, #{$light-color});
box-shadow: 0 0 0 2px #{$light-color}, #{$shadow};
@ -135,8 +147,8 @@ const FIXED_L = 0.55;
}
&::-moz-range-thumb {
height: 24px;
width: 24px;
height: 32px;
width: 32px;
border-radius: 1000px;
background-color: var(--thumb-color, white);
border: 2px solid white;
@ -153,7 +165,7 @@ const FIXED_L = 0.55;
.preview {
height: 40px;
border-radius: var(--border-radius);
box-shadow: $shadow-border;
box-shadow: 0 0 0 1px rgba($text-color, 0.18);
}
`,
})

View file

@ -6,6 +6,9 @@ import {
signal,
OnChanges,
SimpleChanges,
ElementRef,
HostListener,
inject,
} from '@angular/core';
@Component({
@ -19,7 +22,7 @@ import {
[class.always-shadow]="alwaysDropShadow()"
>
<div class="background" [class.active]="open()"></div>
<div class="top" (click)="open.update(v => !v)">
<div class="top" (click)="toggleOpen($event)">
<p>{{ resolvedSelected() ?? placeholder() }}</p>
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
</div>
@ -33,7 +36,9 @@ import {
(blur)="onRename(item, $any($event.target).value)"
/>
} @else {
<p (click)="onSelectItem(item)">{{ item }}</p>
<button class="option" type="button" (click)="onSelectItem(item)">
{{ item }}
</button>
}
}
<div class="add-row">
@ -43,9 +48,20 @@ import {
placeholder="Add a value…"
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
/>
<button (click)="onAdd(addInput.value); addInput.value = ''">Add</button>
<button
class="add-button"
type="button"
(click)="onAdd(addInput.value); addInput.value = ''"
>
Add
</button>
@if (editable()) {
<button class="pen" [class.active]="editing()" (click)="editing.update(v => !v)">
<button
class="pen"
type="button"
[class.active]="editing()"
(click)="editing.update(v => !v)"
>
<img src="assets/pen.svg" alt="Edit" />
</button>
}
@ -58,6 +74,7 @@ import {
@import '../../../../library/main';
$inner-padding: var(--medium-padding);
$dropdown-shadow: 0 4px 14px rgba($text-color, 0.16), $shadow-border;
:host {
display: block;
@ -80,13 +97,22 @@ import {
align-items: center;
position: relative;
cursor: pointer;
min-height: 46px;
box-sizing: border-box;
gap: var(--small-padding);
p {
display: inline-block;
display: block;
@include sub-title-text();
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
img.arrow {
flex: 0 0 auto;
@include square(16px);
transition: transform $long-animation-time;
@ -98,81 +124,129 @@ import {
.bottom-container {
width: 100%;
height: 300px;
position: absolute;
overflow-y: hidden;
top: 100%;
left: 0;
right: 0;
overflow: visible;
pointer-events: none;
z-index: 5;
.bottom {
position: absolute;
position: relative;
width: 100%;
pointer-events: all;
pointer-events: none;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 0 0 var(--border-radius) var(--border-radius);
padding: $inner-padding;
padding-top: 0;
@include inner-spacing($inner-padding);
padding-top: var(--small-padding);
gap: var(--small-padding);
// Default (closed) state — also the target of the close transition.
background-color: transparent;
box-shadow: none;
transform: translateY(-100%);
transform: translateY(-8px);
opacity: 0;
visibility: hidden;
// Clip the top edge so the panel's shadow can't bleed back up into
// the chip area; sides + bottom get a 10px slack for the shadow.
clip-path: inset(0 -10px -10px -10px);
// Delay the visibility change until after the slide animation finishes
// so the panel stays visible while it animates closed.
transition:
transform $long-animation-time,
opacity $long-animation-time,
background-color $long-animation-time,
box-shadow $long-animation-time,
visibility 0s $long-animation-time;
&.open {
visibility: visible;
pointer-events: all;
transform: none;
opacity: 1;
background-color: $light-color;
box-shadow: $shadow;
// Show shadow on left/right/bottom only; clip the top edge so the
// shadow doesn't bleed over the seam where .bottom meets .top.
clip-path: inset(0 -6px -6px -6px);
box-shadow: $dropdown-shadow;
// On open, visibility flips immediately (no delay); transform +
// colors + shadow animate over $long-animation-time.
transition:
transform $long-animation-time,
opacity $long-animation-time,
background-color $long-animation-time,
box-shadow $long-animation-time,
visibility 0s 0s;
}
p {
.option {
@include sub-title-text();
display: inline-block;
display: flex;
align-items: center;
width: 100%;
min-height: 36px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
&:after {
display: none;
}
@media (max-width: $mobile-width) {
min-height: 42px;
}
}
input[type='text'] {
@include sub-title-text();
width: 100%;
min-height: 36px;
box-sizing: border-box;
text-align: left;
&::placeholder {
color: rgba($text-color, 0.72);
opacity: 1;
}
@media (max-width: $mobile-width) {
min-height: 42px;
}
}
.add-row {
height: 32px;
@media (max-width: $mobile-width) { height: 24px; }
min-height: 40px;
position: relative;
width: 100%;
display: flex;
align-items: center;
align-items: flex-end;
gap: var(--small-padding);
input[type='text'] {
flex: 1;
min-height: 0;
padding: 0;
border-bottom: solid 2px transparent;
&:focus,
&:focus-visible {
box-shadow: none;
border-bottom-color: $text-color;
}
}
button {
margin: 0;
position: static;
position: relative;
flex: 0 0 auto;
&.add-button {
align-self: flex-end;
}
&.pen {
opacity: 0.25;
@ -184,6 +258,10 @@ import {
background: transparent;
position: relative;
// Kill the global button's hover-grow underline pseudo-element
// for the icon-only edit control.
&:after { content: none; display: none; }
img {
@include square(16px);
}
@ -224,16 +302,18 @@ import {
width: 100%;
@include card();
z-index: 3;
box-sizing: border-box;
transition:
box-shadow $long-animation-time,
height $long-animation-time,
border-radius $long-animation-time;
&.active {
box-shadow: $shadow;
// Show shadow on top/left/right only; clip the bottom edge so the
// shadow doesn't bleed over the seam where .top meets .bottom.
clip-path: inset(-6px -6px 0 -6px);
// Same shadow recipe as the panel below so the two halves read as
// one continuous card. Clip the bottom edge so this shadow doesn't
// bleed across the seam where .top meets .bottom.
box-shadow: $dropdown-shadow;
clip-path: inset(-10px -10px 0 -10px);
}
}
@ -251,25 +331,31 @@ import {
}
}
// Hover lifts the chip — but only when the dropdown is closed. When
// it's open the chip is already showing $dropdown-shadow and a hover
// override would make the top heavier than the panel below.
&:hover {
@media (min-width: $mobile-width) {
.background { box-shadow: $shadow; }
.background:not(.active) { box-shadow: $shadow; }
}
}
&.shadow-border {
.background.active {
box-shadow: $shadow-border;
clip-path: inset(-6px -6px 0 -6px);
clip-path: inset(-10px -10px 0 -10px);
}
.bottom.open {
box-shadow: $shadow-border;
}
}
&.shadow-border:hover {
.background { box-shadow: $shadow-border; }
.background:not(.active) { box-shadow: $shadow-border; }
}
&.always-shadow {
.background { box-shadow: $shadow; }
.background:not(.active) { box-shadow: $shadow; }
// When open, clip the bottom so the always-on shadow doesn't bleed
// over the seam; restore full shadow when closed.
&:has(.bottom.open) .background {
@ -309,6 +395,7 @@ export class SelectAddComponent implements OnChanges {
// ── Internal state ─────────────────────────────────────────────────────────
readonly open = signal(false);
readonly editing = signal(false);
private readonly host = inject(ElementRef<HTMLElement>);
// Resolved values that merge old + new API
protected resolvedItems(): string[] {
@ -332,6 +419,20 @@ export class SelectAddComponent implements OnChanges {
// Nothing to do — signals handle reactivity
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (!this.open()) return;
if (!this.host.nativeElement.contains(event.target as Node)) {
this.open.set(false);
this.editing.set(false);
}
}
toggleOpen(event: MouseEvent): void {
event.stopPropagation();
this.open.update((v) => !v);
}
onSelectItem(item: string): void {
this.select.emit(item);
// Legacy compat: also emit the index

View file

@ -30,7 +30,12 @@ import {
$size: 30px;
@include center-child();
@include inner-spacing(var(--medium-padding), $horizontal: true);
gap: var(--medium-padding);
@media (max-width: $mobile-width) {
width: 100%;
gap: var(--small-padding);
}
.toggle {
display: contents;
@ -41,7 +46,7 @@ import {
// Fixed width (not max-width) so multiple toggles align column-wise
// — the thumb position is identical across rows regardless of label.
flex: 0 0 auto;
width: 4 * $size;
width: var(--toggle-label-width, #{4 * $size});
box-sizing: border-box;
padding: 0 var(--small-padding);
line-height: 1.3;
@ -50,10 +55,19 @@ import {
&.active { font-weight: bold; }
&:first-of-type { text-align: right; }
&:last-of-type { text-align: left; }
@media (max-width: $mobile-width) {
flex: 1 1 0;
width: auto;
min-width: 0;
padding: 0;
overflow-wrap: anywhere;
}
}
label {
display: block;
flex: 0 0 auto;
input[type='checkbox'] {
-webkit-appearance: none;

View file

@ -17,16 +17,26 @@ import { getColorOfTag } from '../../utils/color';
<div
class="container"
[class.show-hover]="pending().length > 0"
(click)="expanded.update(v => !v)"
(pointerdown)="$event.stopPropagation()"
(mousedown)="$event.stopPropagation()"
(touchstart)="$event.stopPropagation()"
(pointerup)="toggleExpanded($event)"
(touchend)="toggleExpanded($event)"
(click)="toggleExpanded($event)"
>
<p class="header">
<p
class="header"
(pointerup)="toggleExpanded($event)"
(touchend)="toggleExpanded($event)"
(click)="toggleExpanded($event)"
>
<strong>{{ pending().length === 0 ? '' : pending().length }}</strong>
{{ pending().length === 0 ? '' : pending().length === 1 ? 'task' : 'tasks' }}
</p>
<div
class="all-task"
#all
[style.height.px]="expanded() ? all.scrollHeight : 0"
class="all-task"
[style.max-height.px]="expanded() ? all.scrollHeight : 0"
>
@for (b of pending(); track b.id) {
<div class="task-container">
@ -34,11 +44,15 @@ import { getColorOfTag } from '../../utils/color';
type="button"
class="tickbox"
[style.background-color]="colorOf(b.tag)"
(pointerup)="$event.stopPropagation()"
(touchend)="$event.stopPropagation()"
(click)="$event.stopPropagation(); markDone.emit(b)"
[attr.aria-label]="'Mark ' + (b.description || b.tag) + ' done'"
></button>
<p
[style.color]="colorOf(b.tag)"
(pointerup)="$event.stopPropagation()"
(touchend)="$event.stopPropagation()"
(click)="$event.stopPropagation(); edit.emit(b)"
>{{ b.description || b.tag }}</p>
</div>
@ -82,10 +96,24 @@ import { getColorOfTag } from '../../utils/color';
:first-child { margin-top: var(--small-padding); }
height: 0;
box-sizing: border-box;
transition: height $long-animation-time;
overflow-y: hidden;
transition: max-height $long-animation-time;
/*
* Clip during the open/close animation. We animate to the element's
* exact content height (read off #all in the template) so tall lists
* simply scroll inside the outer .container (max-height: 30vh)
* .all-task itself is NOT a nested scroller. A nested overflow-y:auto
* here pops a scrollbar the instant a tickbox grows on hover, because
* its scale(1.05) + shadow widen the scrollable-overflow box.
*/
overflow: hidden;
// Sideways breathing room so the clip doesn't shear the tickbox's
// hover shadow; negative side margins keep rows flush with the header,
// and the bottom padding clears the last row's shadow.
margin: 0 calc(var(--small-padding) / -2);
padding: 0 calc(var(--small-padding) / 2) calc(var(--small-padding) / 2);
.task-container {
display: flex;
@ -106,14 +134,16 @@ import { getColorOfTag } from '../../utils/color';
// done without opening the edit carousel. Hover & focus reveal a
// subtle inner check mark.
.tickbox {
flex: 0 0 auto;
all: unset; // strip native button styles
flex: 0 0 24px;
cursor: pointer;
position: relative;
box-sizing: border-box;
@include square(24px);
min-width: 24px;
min-height: 24px;
@media (max-width: $mobile-width) {
@include square(20px);
@include square(24px);
}
border-radius: 4px;
box-shadow: $shadow-border;
@ -123,14 +153,25 @@ import { getColorOfTag } from '../../utils/color';
content: '✓';
position: absolute;
inset: 0;
/*
* Neutralise the global animated-underline bar from
* forms.scss (button:after { height: 2px; width: 0->100% on
* hover; background-color: $text-color }). The all:unset on the
* button does NOT reach the pseudo-element, so without these
* resets the bar paints a dark stripe across the top AND
* squashes this box to 2px which centres the glyph near the
* top instead of the middle.
*/
width: 100%;
height: 100%;
background: none;
@include center-child();
color: $light-color;
font-size: 18px;
font-weight: bold;
line-height: 1;
opacity: 0.5;
font: bold 18px/1 $normal-font; // re-assert font (all:unset dropped it to serif)
opacity: 0; // hidden at rest — only revealed on hover/focus/active
text-shadow: 0 0 1px rgba(0, 0, 0, 0.4);
transform: translateY(2px);
// The ✓ glyph sits a touch high in its em-box; nudge to optical centre.
transform: translateY(1px);
transition: opacity $short-animation-time, transform $short-animation-time;
}
@ -142,7 +183,7 @@ import { getColorOfTag } from '../../utils/color';
}
&:active {
transform: scale(0.95);
&::after { opacity: 1; transform: translateY(2px) scale(1.05); }
&::after { opacity: 1; transform: translateY(1px) scale(1.05); }
}
}
@ -152,10 +193,13 @@ import { getColorOfTag } from '../../utils/color';
overflow-x: hidden;
text-align: left;
flex: 1 1 auto;
min-width: 0;
cursor: pointer;
@media (max-width: $mobile-width) {
font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2);
font-size: var(--medium-font-size);
font-weight: 500;
filter: saturate(0.85) brightness(0.82);
}
position: relative;
@ -178,6 +222,7 @@ export class TasksComponent {
readonly edit = output<Block>();
readonly expanded = signal(false);
private lastToggleAt = 0;
constructor() {
// Re-sync `expanded` whenever the `initiallyOpen` input changes so flipping
@ -193,4 +238,15 @@ export class TasksComponent {
colorOf(tag: string): string {
return getColorOfTag(tag, this.baseColor());
}
toggleExpanded(event: Event): void {
event.stopPropagation();
if (event.type === 'touchend') {
event.preventDefault();
}
const now = Date.now();
if (now - this.lastToggleAt < 250) return;
this.lastToggleAt = now;
this.expanded.update((v) => !v);
}
}

View file

@ -7,6 +7,10 @@ import {
computed,
effect,
untracked,
AfterViewInit,
OnDestroy,
ElementRef,
viewChild,
} from '@angular/core';
import { Tower, Block } from '../../models';
import { BlockComponent } from '../block/block.component';
@ -26,6 +30,8 @@ interface StyledBlock extends Block {
_opacity: string;
}
const BLOCKS_PER_ROW = 6;
@Component({
selector: 'lt-tower',
standalone: true,
@ -33,51 +39,79 @@ interface StyledBlock extends Block {
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="tower">
<div class="container" [class.trash-highlight]="trashHighlight()">
<div class="tower-header">
<input
#nameInput
type="text"
[value]="tower().name"
[style.color]="towerNameCss()"
(pointerdown)="$event.stopPropagation()"
(blur)="onRename($event)"
(keydown.enter)="nameInput.blur()"
/>
<button
type="button"
class="edit-tower"
aria-label="Edit tower"
(click)="$event.stopPropagation(); showSettings.set(true)"
>
<img src="assets/pen.svg" alt="" />
</button>
</div>
<div
class="container"
[class.trash-highlight]="trashHighlight()"
>
<lt-tasks
[pending]="pending()"
[baseColor]="tower().base_color"
[initiallyOpen]="keepTasksOpen()"
(pointerdown)="$event.stopPropagation()"
(markDone)="onMarkTaskDone($event)"
(edit)="onEditBlock($event)"
/>
<img
src="assets/plus-sign.svg"
class="add-block"
alt="Add block"
(click)="$event.stopPropagation(); openAddBlock()"
/>
<div
#stackZone
class="stack-zone"
[style.--block-stack-height]="blockStackHeight()"
>
<img
src="assets/plus-sign.svg"
class="add-block"
alt="Add block"
(pointerdown)="$event.stopPropagation()"
(click)="$event.stopPropagation(); openAddBlock()"
/>
<div class="block-container-container">
<div class="block-container">
@for (b of visibleBlocks(); track b.id) {
<lt-block
[block]="b"
[baseColor]="tower().base_color"
[class]="b._anim"
[style.transform]="b._transform"
[style.opacity]="b._opacity"
(clicked)="onEditBlock(b)"
/>
}
<div class="block-container-container">
<div class="block-container">
@for (b of visibleBlocks(); track b.id) {
<lt-block
[block]="b"
[baseColor]="tower().base_color"
[class]="b._anim"
[style.transform]="b._transform"
[style.opacity]="b._opacity"
(clicked)="onEditBlock(b)"
/>
}
</div>
</div>
</div>
</div>
<input
#nameInput
type="text"
[value]="tower().name"
[style.color]="towerNameCss()"
(blur)="onRename($event)"
(keydown.enter)="nameInput.blur()"
/>
@if (hiddenBlockCount() > 0) {
<p class="more-blocks">+ {{ hiddenBlockCount() }} more</p>
}
</div>
@if (editEntry(); as entry) {
<lt-modal (close)="closeEdit()">
<lt-block-edit
[viewTitle]="editViewTitle()"
[blocks]="filteredForEntry()"
[activeBlockId]="entry.activeId"
[tags]="towerTags()"
@ -92,7 +126,12 @@ interface StyledBlock extends Block {
@if (showSettings()) {
<lt-modal (close)="showSettings.set(false)">
<lt-tower-settings [tower]="tower()" (save)="onTowerSave($event)" (delete)="onTowerDelete()" />
<lt-tower-settings
[tower]="tower()"
(save)="onTowerSave($event)"
(delete)="onTowerDelete()"
(close)="showSettings.set(false)"
/>
</lt-modal>
}
`,
@ -100,7 +139,9 @@ interface StyledBlock extends Block {
@import '../../../library/main';
:host {
display: block;
cursor: pointer;
min-height: 0;
&.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
@ -140,7 +181,7 @@ interface StyledBlock extends Block {
}
}
input {
.tower-header {
display: none;
}
}
@ -149,8 +190,10 @@ interface StyledBlock extends Block {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
max-width: 100%;
height: 100%;
min-height: 0;
@include inner-spacing(var(--small-padding));
@ -158,7 +201,14 @@ interface StyledBlock extends Block {
display: flex;
flex-direction: column;
flex: 1 1 auto;
margin-bottom: 0;
min-height: 0;
position: relative;
box-sizing: border-box;
container-type: inline-size;
--block-stack-height: 0px;
--add-block-size: 48px;
--add-block-clearance: var(--medium-padding);
@include card();
overflow: hidden;
@ -168,7 +218,9 @@ interface StyledBlock extends Block {
@media (max-width: $mobile-width) {
@include inner-spacing(var(--small-padding));
padding: var(--small-padding);
padding: 0;
--add-block-size: 32px;
--add-block-clearance: var(--small-padding);
}
width: 100%;
@ -189,16 +241,16 @@ interface StyledBlock extends Block {
}
lt-tasks {
flex: 0 0 auto;
flex: 0 1 auto;
min-height: 56px;
max-height: 30vh;
overflow: hidden;
max-height: min(30vh, 45%);
overflow: auto;
display: block;
width: 100%;
@media (max-width: $mobile-width) {
min-height: 44px;
max-height: 25vh;
max-height: min(25vh, 45%);
}
.container {
@ -207,80 +259,169 @@ interface StyledBlock extends Block {
}
}
img.add-block {
flex: 0 0 auto;
align-self: center;
margin: var(--medium-padding) 0;
}
img {
.stack-zone {
position: relative;
z-index: 2;
height: 48px;
flex: 1 1 auto;
min-height: 0;
width: 100%;
@media (max-width: $mobile-width) {
height: 32px;
img {
position: relative;
z-index: 2;
height: 48px;
@media (max-width: $mobile-width) {
height: 32px;
}
opacity: 0.33;
transition: opacity $long-animation-time;
cursor: pointer;
&:hover {
opacity: 1;
}
}
opacity: 0.33;
transition: opacity $long-animation-time;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.block-container-container {
position: relative;
flex: 1;
.block-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: flex-start;
align-items: flex-end;
img.add-block {
position: absolute;
bottom: 0;
width: 100%;
transform: scaleY(-1);
z-index: 3;
left: 50%;
top: max(
0px,
min(
calc(50% - var(--add-block-size) / 2),
calc(100% - var(--block-stack-height) - var(--add-block-clearance) - var(--add-block-size))
)
);
transform: translateX(-50%);
}
/* Default resting position for all blocks before JS sets them */
* {
transform: translateY(500%);
}
.block-container-container {
position: absolute;
inset: 0;
.descend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
opacity 500ms cubic-bezier(0.5, 0, 1, 0);
}
.block-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: flex-start;
align-items: flex-end;
position: absolute;
bottom: 0;
width: 100%;
transform: scaleY(-1);
.ascend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
/* Default resting position for all blocks before JS sets them */
* {
transform: translateY(500%);
}
.descend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
opacity 500ms cubic-bezier(0.5, 0, 1, 0);
}
.ascend {
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0),
opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
}
}
}
}
}
input[type='text'] {
font-size: var(--small-font-size);
.more-blocks {
@include small-text();
position: absolute;
top: calc(100% + var(--small-padding));
left: 0;
right: 0;
margin: 0;
line-height: 1;
color: rgba($text-color, 0.72);
text-align: center;
pointer-events: none;
user-select: none;
}
@media (min-width: $mobile-width) {
width: 50%;
.tower-header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
input[type='text'] {
box-sizing: border-box;
min-width: 0;
font-size: var(--small-font-size);
text-align: center;
/* Truncate long titles with an ellipsis instead of wrapping. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* Reserve a symmetric gutter on each side. The right one keeps the
title and its focus underline clear of the absolutely-positioned
pen (so the underline stops before it); the equal left one keeps
the centered title optically centered. (pen 22px + 4px gap.) */
width: calc(100% - 52px);
@media (max-width: $mobile-width) {
width: calc(100% - 60px);
}
}
@media (max-width: $mobile-width) {
width: 100%;
.edit-tower {
all: unset;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
box-sizing: border-box;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--border-radius);
cursor: pointer;
opacity: 0.35;
transition: opacity $short-animation-time, box-shadow $long-animation-time, background-color $short-animation-time;
/* Suppress the global button's animated hover underline
(button::after), which the 'all: unset' reset doesn't reach. */
&:after {
content: none;
}
img {
width: 13px;
height: 13px;
opacity: 1;
}
&:hover,
&:focus-visible {
opacity: 1;
background-color: rgba($light-color, 0.86);
box-shadow: $shadow-border;
}
@media (max-width: $mobile-width) {
width: 26px;
height: 26px;
opacity: 0.55;
}
}
}
}
}
`,
})
export class TowerComponent {
export class TowerComponent implements AfterViewInit, OnDestroy {
// ── Inputs ─────────────────────────────────────────────────────────────────
readonly tower = input.required<Tower>();
/** Set by page component when this tower is being dragged over trash. */
@ -308,6 +449,11 @@ export class TowerComponent {
* which list of blocks to show and which one to focus initially. */
readonly editEntry = signal<EditEntry | null>(null);
readonly showSettings = signal(false);
readonly hiddenBlockCount = signal(0);
private readonly stackZone = viewChild<ElementRef<HTMLElement>>('stackZone');
private readonly maxVisibleBlocks = signal<number | null>(null);
private resizeObserver: ResizeObserver | null = null;
// ── Derived ────────────────────────────────────────────────────────────────
/** Pending (not-done) blocks — fed to the tasks accordion. */
@ -324,6 +470,13 @@ export class TowerComponent {
return this.tower().blocks.filter(b => b.is_done === isDone);
});
readonly editViewTitle = computed(() => {
const entry = this.editEntry();
if (!entry) return '';
const prefix = entry.filter === 'done' ? 'Completed tasks' : 'Tasks';
return `${prefix} of ${this.tower().name}`;
});
/** Unique tags from existing blocks of this tower. */
readonly towerTags = computed(() => {
const set = new Set<string>();
@ -339,6 +492,11 @@ export class TowerComponent {
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
readonly visibleBlocks = this._visibleBlocks.asReadonly();
readonly blockStackHeight = computed(() => {
const rows = Math.ceil(this.visibleBlocks().length / BLOCKS_PER_ROW);
const cqw = rows * (100 / BLOCKS_PER_ROW);
return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`;
});
private prevDoneIds: string[] = [];
private isFirstRun = true;
@ -346,15 +504,69 @@ export class TowerComponent {
constructor() {
effect(() => {
const range = this.dateRange();
// Always keep ALL done blocks in the visible list. The date filter
// only flips per-block `_anim` so the 1.5s ascend/descend transition
// can animate them in and out of the falling stack.
const maxVisibleBlocks = this.maxVisibleBlocks();
// Reconcile all done blocks, then cap the rendered stack to the rows
// that fit below the tasks and add button.
const allDone = this.tower().blocks.filter((b) => b.is_done);
untracked(() => this.reconcile(allDone, range));
untracked(() => this.reconcile(allDone, range, maxVisibleBlocks));
});
}
private reconcile(allDone: Block[], range: { from: number; to: number } | null): void {
ngAfterViewInit(): void {
const stackZone = this.stackZone()?.nativeElement;
if (!stackZone) return;
this.measureBlockCapacity();
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => this.measureBlockCapacity());
this.resizeObserver.observe(stackZone);
}
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => this.measureBlockCapacity());
}
}
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
}
private measureBlockCapacity(): void {
const stackZone = this.stackZone()?.nativeElement;
if (!stackZone) return;
const width = stackZone.clientWidth;
const height = stackZone.clientHeight;
if (width <= 0 || height <= 0) {
this.maxVisibleBlocks.set(0);
return;
}
const styles = getComputedStyle(stackZone);
const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48);
const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15;
const clearance = this.parseCssPixels(
styles.getPropertyValue('--add-block-clearance'),
fallbackClearance,
);
const rowHeight = width / BLOCKS_PER_ROW;
const availableHeight = Math.max(0, height - addBlockSize - 2 * clearance);
const rows = Math.floor((availableHeight + 0.5) / rowHeight);
this.maxVisibleBlocks.set(Math.max(0, rows) * BLOCKS_PER_ROW);
}
private parseCssPixels(value: string, fallback: number): number {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
private reconcile(
allDone: Block[],
range: { from: number; to: number } | null,
maxVisibleBlocks: number | null,
): void {
const ids = allDone.map((b) => b.id);
const prev = this.prevDoneIds;
const prevSet = new Set(prev);
@ -393,16 +605,39 @@ export class TowerComponent {
});
}
const visibleLimit =
maxVisibleBlocks === null ? Number.POSITIVE_INFINITY : Math.max(0, maxVisibleBlocks);
const restingBlocks = styled.filter((b) => b._opacity === '1');
const hiddenCount = Number.isFinite(visibleLimit)
? Math.max(0, restingBlocks.length - visibleLimit)
: 0;
let visibleStyled = styled;
if (Number.isFinite(visibleLimit)) {
const shownRestingBlocks =
hiddenCount > 0 ? restingBlocks.slice(hiddenCount) : restingBlocks;
const remainingSlots = Math.max(0, visibleLimit - shownRestingBlocks.length);
const transitioningBlocks =
remainingSlots > 0
? styled.filter((b) => b._opacity !== '1').slice(-remainingSlots)
: [];
const shownIds = new Set([
...shownRestingBlocks.map((b) => b.id),
...transitioningBlocks.map((b) => b.id),
]);
visibleStyled = styled.filter((b) => shownIds.has(b.id));
}
this.hiddenBlockCount.set(hiddenCount);
if (grewByOne) {
const newId = newIds[0];
const newBlock = styled.find(b => b.id === newId);
const newBlock = visibleStyled.find(b => b.id === newId);
if (newBlock && inRange(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.
newBlock._anim = '';
newBlock._transform = 'translateY(500%)';
newBlock._opacity = '0';
this._visibleBlocks.set(styled);
this._visibleBlocks.set(visibleStyled);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
newBlock._anim = 'descend';
@ -417,7 +652,7 @@ export class TowerComponent {
}
}
// existing fall-through path (no growth, first run, or new block out of range):
this._visibleBlocks.set(styled);
this._visibleBlocks.set(visibleStyled);
this.prevDoneIds = ids;
this.isFirstRun = false;
@ -472,7 +707,8 @@ export class TowerComponent {
}
onTowerSave(result: TowerSettingsResult): void {
this.showSettings.set(false);
// Tower edits auto-save, so this fires on every change and must NOT close
// the modal — the user closes it via the exit button / backdrop.
this.updateTower.emit(result);
}

View file

@ -7,7 +7,7 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="welcome-card">
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
<h2>Welcome to Life Towers</h2>
@ -60,6 +60,11 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
h2 {
text-align: center;
margin-bottom: var(--medium-padding);
padding: 0 36px;
@media (max-width: $mobile-width) {
padding: 0 28px;
}
}
p.lead { color: $text-color; }
@ -85,6 +90,10 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
border-bottom-color: rgba($accent-color, 0.33);
&:after { background-color: $accent-color; }
}
@media (max-width: $mobile-width) {
gap: var(--small-padding);
}
}
}
`,