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

@ -53,11 +53,8 @@ test.describe('Life Towers visuals', () => {
await createCard
.locator('textarea[placeholder="Write a description here…"]')
.fill('Finish The Brothers Karamazov');
// Toggle to "Task hasn't been finished yet" so this becomes a pending task.
await createCard
.locator('lt-toggle span')
.filter({ hasText: "This task hasn't been finished yet" })
.click();
// Uncheck "Already done" so this becomes a pending task.
await createCard.getByLabel('Already done').uncheck();
await page.getByRole('button', { name: 'Create and exit' }).click();
await page.waitForSelector('section.modal', { state: 'detached' });
@ -67,6 +64,15 @@ test.describe('Life Towers visuals', () => {
await page.waitForTimeout(300);
await page.screenshot({ path: 'visuals/04b-tasks-accordion-with-tickbox.png', fullPage: true });
// Hover the tickbox: must NOT pop a scrollbar in the accordion, must NOT
// paint the global button-underline bar across the top, and the ✓ must stay
// centred (regression guard — see tasks.component .tickbox::after).
await page.locator('lt-tasks .tickbox').first().hover();
await page.waitForTimeout(350);
await page.locator('lt-tasks .container').screenshot({
path: 'visuals/04c-tasks-tickbox-hover.png',
});
// Add a couple more blocks.
for (const desc of ['Read about WebAssembly GC', 'Re-read "Out of the Tar Pit"']) {
await page.locator('img[alt="Add block"]').first().click();

View file

@ -16,6 +16,7 @@
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@angular/service-worker": "^21.2.0",
"@plausible-analytics/tracker": "^0.4.5",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
@ -3519,6 +3520,12 @@
"license": "MIT",
"optional": true
},
"node_modules/@plausible-analytics/tracker": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz",
"integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==",
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",

View file

@ -15,6 +15,7 @@
"packageManager": "npm@10.9.2",
"dependencies": {
"@angular/cdk": "^21.2.13",
"@plausible-analytics/tracker": "^0.4.5",
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",

View file

@ -1,5 +1,6 @@
import { Component, ChangeDetectionStrategy, OnInit, inject } from '@angular/core';
import { StoreService } from './services/store.service';
import { AnalyticsService } from './services/analytics.service';
import { PagesComponent } from './components/pages/pages.component';
@Component({
@ -11,8 +12,10 @@ import { PagesComponent } from './components/pages/pages.component';
})
export class App implements OnInit {
private readonly store = inject(StoreService);
private readonly analytics = inject(AnalyticsService);
ngOnInit(): void {
this.analytics.init();
this.store.init();
}
}

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);
}
}
}
`,

View file

@ -0,0 +1,65 @@
import { Injectable, isDevMode } from '@angular/core';
import {
init as plausibleInit,
track as plausibleTrack,
type PlausibleEventOptions,
} from '@plausible-analytics/tracker';
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
const ANALYTICS_DOMAIN = 'schmelczer.dev/towers';
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private isInitialized = false;
private hasTrackedStart = false;
init(): void {
if (this.isInitialized) return;
try {
plausibleInit({
domain: ANALYTICS_DOMAIN,
endpoint: ANALYTICS_ENDPOINT,
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
logging: isDevMode(),
});
this.isInitialized = true;
} catch (error) {
console.warn('Could not initialize analytics.', error);
}
}
private track(eventName: string, options: PlausibleEventOptions = {}): void {
try {
plausibleTrack(eventName, options);
} catch (error) {
console.warn(`Could not track analytics event "${eventName}".`, error);
}
}
trackStart(): void {
if (this.hasTrackedStart) return;
this.hasTrackedStart = true;
this.track('Start');
}
trackExampleLoaded(): void {
this.track('Example Loaded');
}
trackPageCreated(): void {
this.track('Page Created');
}
trackTowerCreated(): void {
this.track('Tower Created');
}
trackBlockCreated({ isDone }: { isDone: boolean }): void {
this.track('Block Created', { props: { isDone: String(isDone) } });
}
trackBlockCompleted(): void {
this.track('Block Completed');
}
}

View file

@ -1,5 +1,6 @@
import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core';
import { ApiService } from './api.service';
import { AnalyticsService } from './analytics.service';
import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models';
const TOKEN_KEY = 'life-towers.token.v4';
@ -55,9 +56,24 @@ interface PendingPut {
tree: TreeDto;
}
interface ExampleBlockSeed {
tag: string;
desc: string;
done: boolean;
ageHrs: number;
}
interface ExampleDonePattern {
tag: string;
desc: (sequence: number) => string;
}
const EXAMPLE_DONE_BLOCKS_PER_TOWER = 120;
@Injectable({ providedIn: 'root' })
export class StoreService implements OnDestroy {
private readonly api = inject(ApiService);
private readonly analytics = inject(AnalyticsService);
// ── State ──────────────────────────────────────────────────────────────────
private readonly _pages = signal<Page[]>([]);
@ -226,6 +242,8 @@ export class StoreService implements OnDestroy {
towers: [],
};
this._pages.update((pages) => [...pages, page]);
this.analytics.trackStart();
this.analytics.trackPageCreated();
this.scheduleSave();
}
@ -256,6 +274,8 @@ export class StoreService implements OnDestroy {
this._pages.update((pages) =>
pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)),
);
this.analytics.trackStart();
this.analytics.trackTowerCreated();
this.scheduleSave();
}
@ -318,6 +338,8 @@ export class StoreService implements OnDestroy {
: p,
),
);
this.analytics.trackStart();
this.analytics.trackBlockCreated({ isDone: is_done });
this.scheduleSave();
}
@ -336,7 +358,7 @@ export class StoreService implements OnDestroy {
t.id === towerId
? {
...t,
blocks: t.blocks.map((b) => (b.id === blockId ? { ...b, ...patch } : b)),
blocks: this.patchBlockList(t.blocks, blockId, patch).blocks,
}
: t,
),
@ -366,6 +388,7 @@ export class StoreService implements OnDestroy {
}
toggleBlock(pageId: string, towerId: string, blockId: string): void {
let becameDone = false;
this._pages.update((pages) =>
pages.map((p) =>
p.id === pageId
@ -373,21 +396,58 @@ export class StoreService implements OnDestroy {
...p,
towers: p.towers.map((t) =>
t.id === towerId
? {
...t,
blocks: t.blocks.map((b) =>
b.id === blockId ? { ...b, is_done: !b.is_done } : b,
),
}
? (() => {
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,
),
);
this.analytics.trackStart();
if (becameDone) this.analytics.trackBlockCompleted();
this.scheduleSave();
}
private patchBlockList(
blocks: Block[],
blockId: string,
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
): { blocks: Block[]; becameDone: boolean } {
const nextBlocks: Block[] = [];
let completedBlock: Block | null = null;
let becameDone = false;
for (const block of blocks) {
if (block.id !== blockId) {
nextBlocks.push(block);
continue;
}
const updated: Block = { ...block, ...patch };
becameDone = !block.is_done && updated.is_done;
if (becameDone) {
// Display newly completed todos as the newest square, without changing
// created_at because the date slider still filters on creation time.
completedBlock = updated;
} else {
nextBlocks.push(updated);
}
}
return {
blocks: completedBlock ? [...nextBlocks, completedBlock] : nextBlocks,
becameDone,
};
}
/**
* Switch to a different user's token. Any pending writes for the OLD
* account must be cancelled first otherwise a queued PUT could fire
@ -529,30 +589,56 @@ export class StoreService implements OnDestroy {
default_date_from: null,
default_date_to: null,
towers: [
// Done blocks are listed oldest-first so they fill the falling stack
// oldest → newest (left → right), matching the slider's old → new
// labels; pending tasks stay on top for the accordion.
this.makeExampleTower('Reading', { h: 0.05, s: 0.7, l: 0.55 }, now, [
{ tag: 'novel', desc: 'Finish The Brothers Karamazov', done: false, ageHrs: 0 },
{ tag: 'novel', desc: "Read Dostoyevsky's notes", done: true, ageHrs: 2 },
{ tag: 'article', desc: 'How does WebAssembly GC work?', done: true, ageHrs: 6 },
{ tag: 'paper', desc: 'Re-read "Out of the Tar Pit"', done: true, ageHrs: 30 },
{ tag: 'novel', desc: 'Submit a short story', done: true, ageHrs: 72 },
{ tag: 'novel', desc: 'Finish The Brothers Karamazov', done: false, ageHrs: 0 },
...this.makeExampleDoneBlocks(
[
{ tag: 'novel', desc: (n) => `Read chapter ${n}` },
{ tag: 'paper', desc: (n) => `Annotated paper ${n}` },
{ tag: 'article', desc: (n) => `Saved article notes ${n}` },
{ tag: 'essay', desc: (n) => `Drafted reading response ${n}` },
],
2,
8,
),
]),
this.makeExampleTower('Side projects', { h: 0.58, s: 0.65, l: 0.5 }, now, [
{ tag: 'angular', desc: 'Modernise the towers app', done: false, ageHrs: 0 },
{ tag: 'rust', desc: 'Port the sync layer to Tauri', done: false, ageHrs: 1 },
{ tag: 'angular', desc: 'Wire CDK drag-drop', done: true, ageHrs: 24 },
{ tag: 'rust', desc: 'Spike SQLite vs LMDB', done: true, ageHrs: 96 },
{ tag: 'angular', desc: 'Modernise the towers app', done: false, ageHrs: 0 },
{ tag: 'rust', desc: 'Port the sync layer to Tauri', done: false, ageHrs: 1 },
...this.makeExampleDoneBlocks(
[
{ tag: 'angular', desc: (n) => `Refined UI pass ${n}` },
{ tag: 'rust', desc: (n) => `Completed systems spike ${n}` },
{ tag: 'infra', desc: (n) => `Tuned deploy workflow ${n}` },
{ tag: 'docs', desc: (n) => `Captured project note ${n}` },
],
4,
9,
),
]),
this.makeExampleTower('Exercise', { h: 0.36, s: 0.6, l: 0.5 }, now, [
{ tag: 'run', desc: '10k Sunday', done: false, ageHrs: 0 },
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 },
{ tag: 'climb', desc: 'Bouldering session', done: true, ageHrs: 12 },
{ tag: 'run', desc: 'Easy 5k loop', done: true, ageHrs: 48 },
{ tag: 'climb', desc: 'Top-roped 5c', done: true, ageHrs: 96 },
{ tag: 'run', desc: '10k Sunday', done: false, ageHrs: 0 },
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 },
...this.makeExampleDoneBlocks(
[
{ tag: 'run', desc: (n) => `Easy run ${n}` },
{ tag: 'climb', desc: (n) => `Bouldering session ${n}` },
{ tag: 'mobility', desc: (n) => `Mobility circuit ${n}` },
{ tag: 'strength', desc: (n) => `Strength session ${n}` },
],
6,
11,
),
]),
],
};
this._pages.update((pages) => [...pages, page]);
this.analytics.trackStart();
this.analytics.trackExampleLoaded();
this.scheduleSave();
}
@ -560,7 +646,7 @@ export class StoreService implements OnDestroy {
name: string,
base_color: HslColor,
nowSec: number,
blocks: Array<{ tag: string; desc: string; done: boolean; ageHrs: number }>,
blocks: ExampleBlockSeed[],
): Tower {
return {
id: uuidV4(),
@ -576,6 +662,23 @@ export class StoreService implements OnDestroy {
};
}
private makeExampleDoneBlocks(
patterns: ExampleDonePattern[],
newestAgeHrs: number,
spacingHrs: number,
): ExampleBlockSeed[] {
return Array.from({ length: EXAMPLE_DONE_BLOCKS_PER_TOWER }, (_, i) => {
const pattern = patterns[i % patterns.length];
const sequence = Math.floor(i / patterns.length) + 1;
return {
tag: pattern.tag,
desc: pattern.desc(sequence),
done: true,
ageHrs: newestAgeHrs + (EXAMPLE_DONE_BLOCKS_PER_TOWER - 1 - i) * spacingHrs,
};
});
}
ngOnDestroy(): void {
this.cancelPendingWrites();
if (typeof window !== 'undefined') {

View file

@ -218,6 +218,87 @@ describe('StoreService', () => {
expect(api.getData).toHaveBeenCalledTimes(1);
});
it('moves a pending block to the end when it becomes done', async () => {
storage[TOKEN_KEY] = FIXED_UUID;
const api = makeMockApi();
api.getData.mockResolvedValue({
pages: [
{
id: 'page-1',
name: 'Page',
hide_create_tower_button: false,
keep_tasks_open: false,
default_date_from: null,
default_date_to: null,
towers: [
{
id: 'tower-1',
name: 'Tower',
base_color: { h: 0.5, s: 0.5, l: 0.5 },
blocks: [
{
id: 'old-pending',
tag: 'a',
description: 'Created first, completed last',
is_done: false,
created_at: 100,
},
{
id: 'newer-pending',
tag: 'b',
description: 'Still pending',
is_done: false,
created_at: 300,
},
{
id: 'existing-done',
tag: 'c',
description: 'Already done',
is_done: true,
created_at: 200,
},
],
},
],
},
],
} satisfies TreeDto);
const store = configure(api);
await store.init();
store.updateBlock('page-1', 'tower-1', 'old-pending', { is_done: true });
const blocks = store.pages()[0].towers[0].blocks;
expect(blocks.map((b) => b.id)).toEqual([
'newer-pending',
'existing-done',
'old-pending',
]);
expect(blocks[2].created_at).toBe(100);
});
it('loads welcome example data with hundreds of completed squares', () => {
const api = makeMockApi();
const store = configure(api);
store.loadExample();
const [page] = store.pages();
expect(page.name).toBe('Hobbies');
expect(page.towers).toHaveLength(3);
const doneBlocks = page.towers.flatMap((tower) => tower.blocks.filter((b) => b.is_done));
expect(doneBlocks.length).toBeGreaterThanOrEqual(300);
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).toEqual([...doneDates].sort((a, b) => a - b));
}
store.ngOnDestroy();
});
// ── Debounced save ─────────────────────────────────────────────────────────
it('debounces saves: multiple mutations within 750ms → one PUT', async () => {

View file

@ -13,11 +13,24 @@
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<link rel="manifest" href="manifest.webmanifest" />
<link rel="canonical" href="/" data-dynamic-url="canonical" />
<meta property="og:title" content="Life Towers" />
<meta property="og:description" content="Organise your tasks into visual towers of blocks." />
<meta property="og:description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
<meta property="og:type" content="website" />
<meta property="og:image" content="og-image.png" />
<meta property="og:url" content="/" data-dynamic-url="canonical" />
<meta property="og:site_name" content="Life Towers" />
<meta property="og:locale" content="en_US" />
<meta property="og:image" content="/og-image.png" data-dynamic-url="og-image" />
<meta property="og:image:secure_url" content="/og-image.png" data-dynamic-url="og-image" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="3333" />
<meta property="og:image:height" content="1750" />
<meta property="og:image:alt" content="Life Towers task towers preview" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Life Towers" />
<meta name="twitter:description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
<meta name="twitter:image" content="/og-image.png" data-dynamic-url="og-image" />
<meta name="twitter:image:alt" content="Life Towers task towers preview" />
</head>
<body>
<app-root></app-root>

View file

@ -38,6 +38,10 @@ input[type='text'] {
&:focus {
box-shadow: 0 1px $text-color;
}
&:focus-visible {
box-shadow: 0 2px $text-color;
}
}
button {
@ -56,6 +60,19 @@ button {
border-bottom: solid $height #5d576b55;
position: relative;
// Mobile: grow buttons to a ~42px tap target, but pin the label to the bottom
// so the bottom-border underline keeps hugging it (as on desktop). A bare
// min-height vertically centres the text and strands the underline well below
// it. Using flex (not a hit-area pseudo-element) means buttons that `all: unset`
// their styling .tickbox, .swatch, .edit-tower reset these props and opt out
// automatically, instead of inheriting a stray absolute overlay.
@media (max-width: $mobile-width) {
min-height: 42px;
display: inline-flex;
align-items: flex-end;
justify-content: center;
}
&:disabled {
color: #5d576b55;
border-bottom: solid $height #5d576b33;

View file

@ -34,12 +34,31 @@
@mixin exit {
@include square(16px);
background: url('/assets/x-sign.svg') no-repeat center center;
background-size: 50% 50%;
-webkit-appearance: none;
appearance: none;
background: transparent url('/assets/x-sign.svg') no-repeat center center / 50% 50%;
border: 0 !important;
border-bottom: 0 !important;
box-shadow: none;
box-sizing: content-box;
color: transparent;
display: block;
flex: 0 0 auto;
font-size: 0;
line-height: 0;
margin: 0;
min-height: 0;
overflow: hidden;
padding: 8px;
text-decoration: none !important;
@include jump();
&:before,
&:after {
content: none !important;
display: none;
}
}
img {

View file

@ -6,4 +6,22 @@
margin-bottom: $spacing;
}
}
& > lt-modal {
@if $horizontal {
margin-right: 0 !important;
} @else {
margin-bottom: 0 !important;
}
}
// Fixed modal hosts should not make the previous content child count as
// "not last" for spacing.
& > *:not(:last-child):not(lt-modal):not(:has(~ *:not(lt-modal))) {
@if $horizontal {
margin-right: 0;
} @else {
margin-bottom: 0;
}
}
}

View file

@ -1,6 +1,14 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: {
// The package ships only a `module` field, which Vite's resolver
// can't always find — point it at the file directly.
'@plausible-analytics/tracker':
'@plausible-analytics/tracker/plausible.js',
},
},
test: {
globals: true,
environment: 'jsdom',