getting there
This commit is contained in:
parent
f87480e79d
commit
5ac6633d40
20 changed files with 898 additions and 112 deletions
|
|
@ -23,12 +23,14 @@ export interface BlockEditSave {
|
|||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
difficulty: number;
|
||||
}
|
||||
|
||||
interface EditedValue {
|
||||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
difficulty: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
|
@ -73,8 +75,7 @@ interface EditedValue {
|
|||
[selected]="editedFor(b.id).tag"
|
||||
[alwaysDropShadow]="true"
|
||||
[onlyShadowBorder]="true"
|
||||
placeholder="Tag this item…"
|
||||
emptyHint="No tags yet. Type one below to create it."
|
||||
[placeholder]="tagPlaceholder('Tag this item…')"
|
||||
(select)="updateTag(b.id, $event)"
|
||||
(add)="updateTag(b.id, $event)"
|
||||
/>
|
||||
|
|
@ -96,6 +97,26 @@ interface EditedValue {
|
|||
<span>Already done</span>
|
||||
</label>
|
||||
|
||||
<div class="difficulty">
|
||||
<span class="label">Difficulty</span>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Decrease difficulty"
|
||||
[disabled]="editedFor(b.id).difficulty <= 1"
|
||||
(click)="updateDifficulty(b.id, -1); $event.stopPropagation()"
|
||||
>−</button>
|
||||
<span class="value">{{ editedFor(b.id).difficulty }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Increase difficulty"
|
||||
(click)="updateDifficulty(b.id, 1); $event.stopPropagation()"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<button (click)="onDelete(b.id); $event.stopPropagation()">Delete</button>
|
||||
</div>
|
||||
|
|
@ -125,8 +146,7 @@ interface EditedValue {
|
|||
[selected]="newValue().tag"
|
||||
[alwaysDropShadow]="true"
|
||||
[onlyShadowBorder]="true"
|
||||
placeholder="Set a category…"
|
||||
emptyHint="No tags yet. Type one below to create it."
|
||||
[placeholder]="tagPlaceholder('Set a category…')"
|
||||
(select)="updateNewTag($event)"
|
||||
(add)="updateNewTag($event)"
|
||||
/>
|
||||
|
|
@ -147,6 +167,26 @@ interface EditedValue {
|
|||
<span>Already done</span>
|
||||
</label>
|
||||
|
||||
<div class="difficulty">
|
||||
<span class="label">Difficulty</span>
|
||||
<div class="stepper">
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Decrease difficulty"
|
||||
[disabled]="newValue().difficulty <= 1"
|
||||
(click)="updateNewDifficulty(-1); $event.stopPropagation()"
|
||||
>−</button>
|
||||
<span class="value">{{ newValue().difficulty }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label="Increase difficulty"
|
||||
(click)="updateNewDifficulty(1); $event.stopPropagation()"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<button
|
||||
(click)="submitNew(); $event.stopPropagation()"
|
||||
|
|
@ -394,6 +434,68 @@ interface EditedValue {
|
|||
}
|
||||
}
|
||||
|
||||
.difficulty {
|
||||
@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);
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--small-padding);
|
||||
|
||||
.value {
|
||||
min-width: 1.5em;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
button.step {
|
||||
all: unset; // strip native + global button styles
|
||||
@include square(22px);
|
||||
@include center-child();
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
background: $light-color;
|
||||
box-shadow: $shadow-border;
|
||||
cursor: pointer;
|
||||
// all:unset drops the font to serif and the global button's hover
|
||||
// underline (button::after) survives the reset — re-assert both.
|
||||
font: bold 18px/1 $normal-font;
|
||||
color: $text-color;
|
||||
user-select: none;
|
||||
transition: box-shadow $long-animation-time, transform $short-animation-time, opacity $short-animation-time;
|
||||
|
||||
&::after { content: none; }
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
@include square(26px);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
box-shadow: $shadow;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
height: 32px;
|
||||
@media (max-width: $mobile-width) {
|
||||
|
|
@ -454,6 +556,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
tag: '',
|
||||
description: '',
|
||||
is_done: true,
|
||||
difficulty: 1,
|
||||
});
|
||||
|
||||
// 1-based index of the centered card. 0/N+2 are placeholders.
|
||||
|
|
@ -471,6 +574,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
tag: b.tag,
|
||||
description: b.description,
|
||||
is_done: b.is_done,
|
||||
difficulty: b.difficulty ?? 1,
|
||||
});
|
||||
}
|
||||
untracked(() => this.editedValues.set(m));
|
||||
|
|
@ -510,6 +614,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
tag: '',
|
||||
description: '',
|
||||
is_done: false,
|
||||
difficulty: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -519,6 +624,10 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent';
|
||||
}
|
||||
|
||||
tagPlaceholder(fallback: string): string {
|
||||
return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback;
|
||||
}
|
||||
|
||||
colorOfNewTag = computed(() => {
|
||||
const t = this.newValue().tag;
|
||||
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
|
||||
|
|
@ -561,6 +670,12 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
this.flushExisting(id);
|
||||
}
|
||||
|
||||
updateDifficulty(id: string, delta: number): void {
|
||||
const next = Math.max(1, this.editedFor(id).difficulty + delta);
|
||||
this.patchEdited(id, { difficulty: next });
|
||||
this.flushExisting(id);
|
||||
}
|
||||
|
||||
private patchEdited(id: string, patch: Partial<EditedValue>): void {
|
||||
this.editedValues.update((m) => {
|
||||
const v = m.get(id);
|
||||
|
|
@ -574,7 +689,13 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
flushExisting(id: string): void {
|
||||
const v = this.editedFor(id);
|
||||
if (!v.tag) return; // Skip empty saves
|
||||
this.save.emit({ id, tag: v.tag, description: v.description, is_done: v.is_done });
|
||||
this.save.emit({
|
||||
id,
|
||||
tag: v.tag,
|
||||
description: v.description,
|
||||
is_done: v.is_done,
|
||||
difficulty: v.difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
onDelete(id: string): void {
|
||||
|
|
@ -595,6 +716,10 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
this.newValue.update((v) => ({ ...v, is_done }));
|
||||
}
|
||||
|
||||
updateNewDifficulty(delta: number): void {
|
||||
this.newValue.update((v) => ({ ...v, difficulty: Math.max(1, v.difficulty + delta) }));
|
||||
}
|
||||
|
||||
submitNew(): void {
|
||||
const v = this.newValue();
|
||||
if (!v.tag) return;
|
||||
|
|
@ -603,6 +728,7 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
tag: v.tag,
|
||||
description: v.description,
|
||||
is_done: v.is_done,
|
||||
difficulty: v.difficulty,
|
||||
});
|
||||
this.close.emit();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
output,
|
||||
input,
|
||||
signal,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
|
|
@ -23,7 +24,17 @@ import { ModalStateService } from '../../services/modal-state.service';
|
|||
[class.active]="active()"
|
||||
(click)="onBackdropClick($event)"
|
||||
>
|
||||
<div class="modal__dialog" #dialog cdkTrapFocus cdkTrapFocusAutoCapture (keydown.escape)="onClose()">
|
||||
<div
|
||||
class="modal__dialog"
|
||||
#dialog
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="labelledBy()"
|
||||
[attr.aria-describedby]="describedBy()"
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
(keydown.escape)="onClose()"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -73,6 +84,8 @@ import { ModalStateService } from '../../services/modal-state.service';
|
|||
`,
|
||||
})
|
||||
export class ModalComponent implements AfterViewInit, OnDestroy {
|
||||
readonly labelledBy = input<string | null>(null);
|
||||
readonly describedBy = input<string | null>(null);
|
||||
readonly close = output<void>();
|
||||
|
||||
// The active signal starts false; AfterViewInit flips it true on next tick
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
[tower]="tower"
|
||||
[dateRange]="dateRange()"
|
||||
[keepTasksOpen]="page().keep_tasks_open"
|
||||
[animateInitialStack]="animateInitialStack()"
|
||||
(cdkDragStarted)="onTowerDragStart(tower.id)"
|
||||
(updateTower)="onUpdateTower(tower.id, $event)"
|
||||
(deleteTowerRequest)="onDeleteTower(tower.id)"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ interface BlockPatch {
|
|||
tag: string;
|
||||
description: string;
|
||||
is_done: boolean;
|
||||
difficulty: number;
|
||||
}
|
||||
|
||||
/** Minimum blocks before the date-range slider becomes visible. */
|
||||
|
|
@ -61,6 +62,7 @@ const MIN_BLOCKS_FOR_SLIDER = 2;
|
|||
})
|
||||
export class PageComponent {
|
||||
readonly page = input.required<Page>();
|
||||
readonly animateInitialStack = input<boolean>(false);
|
||||
readonly dragHappening = output<boolean>();
|
||||
|
||||
protected readonly store = inject(StoreService);
|
||||
|
|
@ -157,6 +159,7 @@ export class PageComponent {
|
|||
result.tag,
|
||||
result.description,
|
||||
result.is_done,
|
||||
result.difficulty,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@
|
|||
|
||||
<div class="page-container">
|
||||
@if (selectedPage(); as page) {
|
||||
<lt-page [page]="page" (dragHappening)="dragHappening.set($event)" />
|
||||
<lt-page
|
||||
[page]="page"
|
||||
[animateInitialStack]="page.id === animateInitialStackPageId()"
|
||||
(dragHappening)="dragHappening.set($event)"
|
||||
/>
|
||||
} @else {
|
||||
<p>Add a new page to get started!</p>
|
||||
}
|
||||
|
|
@ -53,7 +57,11 @@
|
|||
}
|
||||
|
||||
@if (showWelcome()) {
|
||||
<lt-modal (close)="showWelcome.set(false)">
|
||||
<lt-modal
|
||||
labelledBy="welcome-title"
|
||||
describedBy="welcome-description"
|
||||
(close)="showWelcome.set(false)"
|
||||
>
|
||||
<lt-welcome
|
||||
(close)="showWelcome.set(false)"
|
||||
(startFresh)="showWelcome.set(false)"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
signal,
|
||||
computed,
|
||||
effect,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { StoreService } from '../../services/store.service';
|
||||
import { PageComponent } from '../page/page.component';
|
||||
|
|
@ -22,7 +23,7 @@ import { Page } from '../../models';
|
|||
templateUrl: './pages.component.html',
|
||||
styleUrl: './pages.component.scss',
|
||||
})
|
||||
export class PagesComponent {
|
||||
export class PagesComponent implements OnDestroy {
|
||||
protected readonly store = inject(StoreService);
|
||||
|
||||
/** ID of currently selected page within store.pages(). */
|
||||
|
|
@ -32,6 +33,8 @@ export class PagesComponent {
|
|||
readonly dragHappening = signal(false);
|
||||
readonly showWelcome = signal(false);
|
||||
readonly confirmDeletePageId = signal<string | null>(null);
|
||||
readonly animateInitialStackPageId = signal<string | null>(null);
|
||||
private exampleAnimationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
|
|
@ -44,10 +47,27 @@ export class PagesComponent {
|
|||
}
|
||||
|
||||
onLoadExample(): void {
|
||||
this.store.loadExample();
|
||||
const pageId = this.store.loadExample();
|
||||
this.selectedPageId.set(pageId);
|
||||
this.animateInitialStackPageId.set(pageId);
|
||||
if (this.exampleAnimationTimer !== null) {
|
||||
clearTimeout(this.exampleAnimationTimer);
|
||||
}
|
||||
this.exampleAnimationTimer = setTimeout(() => {
|
||||
if (this.animateInitialStackPageId() === pageId) {
|
||||
this.animateInitialStackPageId.set(null);
|
||||
}
|
||||
this.exampleAnimationTimer = null;
|
||||
}, 2500);
|
||||
this.showWelcome.set(false);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.exampleAnimationTimer !== null) {
|
||||
clearTimeout(this.exampleAnimationTimer);
|
||||
}
|
||||
}
|
||||
|
||||
readonly pageNames = computed(() => this.store.pages().map((p) => p.name));
|
||||
|
||||
readonly selectedPage = computed<Page | null>(() => {
|
||||
|
|
|
|||
|
|
@ -28,9 +28,6 @@ import {
|
|||
</div>
|
||||
<div class="bottom-container">
|
||||
<div class="bottom" [class.open]="open()">
|
||||
@if (resolvedItems().length === 0 && emptyHint()) {
|
||||
<p class="empty-hint">{{ emptyHint() }}</p>
|
||||
}
|
||||
@for (item of resolvedItems(); track item) {
|
||||
@if (editing()) {
|
||||
<input
|
||||
|
|
@ -204,14 +201,6 @@ import {
|
|||
}
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
@include medium-text();
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
color: rgba($text-color, 0.72);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
@include sub-title-text();
|
||||
width: 100%;
|
||||
|
|
@ -384,7 +373,6 @@ export class SelectAddComponent implements OnChanges {
|
|||
readonly selected = input<string | null>(null);
|
||||
readonly editable = input<boolean>(false);
|
||||
readonly placeholder = input<string>('Select…');
|
||||
readonly emptyHint = input<string>('');
|
||||
readonly alwaysDropShadow = input<boolean>(false);
|
||||
readonly onlyShadowBorder = input<boolean>(false);
|
||||
|
||||
|
|
@ -419,7 +407,7 @@ export class SelectAddComponent implements OnChanges {
|
|||
protected resolvedSelected(): string | null {
|
||||
// New API: string
|
||||
const s = this.selected();
|
||||
if (s != null) return s;
|
||||
if (s != null && s.trim()) return s;
|
||||
// Legacy API: index into options
|
||||
const idx = this.selectedIndex();
|
||||
const opts = this.resolvedItems();
|
||||
|
|
|
|||
|
|
@ -21,24 +21,100 @@ import { TowerSettingsComponent, TowerSettingsResult } from '../modal/tower-sett
|
|||
import { toCss } from '../../utils/color';
|
||||
|
||||
/** Tracks which entry path the block-edit modal was opened from. */
|
||||
type EditEntry = { filter: 'done' | 'pending'; activeId: string | null };
|
||||
interface EditEntry {
|
||||
filter: 'done' | 'pending';
|
||||
activeId: string | null;
|
||||
}
|
||||
|
||||
/** A done block augmented with per-render animation state. */
|
||||
interface StyledBlock extends Block {
|
||||
export interface StyledBlock extends Block {
|
||||
_anim: '' | 'descend' | 'ascend';
|
||||
_transform: string;
|
||||
_opacity: string;
|
||||
}
|
||||
|
||||
/** One rendered square. A block draws `difficulty` squares, all sharing the
|
||||
* block's color + animation state. */
|
||||
interface RenderSquare {
|
||||
key: string;
|
||||
block: StyledBlock;
|
||||
}
|
||||
|
||||
const BLOCKS_PER_ROW = 6;
|
||||
|
||||
/** How many squares a block draws (its difficulty, clamped to >= 1). */
|
||||
function squareCount(block: Block): number {
|
||||
const n = Math.floor(block.difficulty ?? 1);
|
||||
return Number.isFinite(n) && n > 0 ? n : 1;
|
||||
}
|
||||
|
||||
function totalSquares(blocks: Block[]): number {
|
||||
return blocks.reduce((sum, block) => sum + squareCount(block), 0);
|
||||
}
|
||||
|
||||
/** Pick the newest blocks (array tail) whose cumulative square-count (each
|
||||
* block costs `difficulty` squares) fits within `limit`, preserving order. */
|
||||
function fitNewestBySquares(blocks: StyledBlock[], limit: number): StyledBlock[] {
|
||||
const chosen: StyledBlock[] = [];
|
||||
let used = 0;
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
const cost = squareCount(blocks[i]);
|
||||
if (used + cost > limit) break;
|
||||
used += cost;
|
||||
chosen.unshift(blocks[i]);
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
export function selectVisibleStyledBlocks(
|
||||
styled: StyledBlock[],
|
||||
visibleLimit: number,
|
||||
enteringInRangeId: string | null,
|
||||
): { visibleStyled: StyledBlock[]; hiddenCount: number } {
|
||||
// `visibleLimit` is a number of SQUARE slots. A block draws `difficulty`
|
||||
// squares, so we cap by cumulative square cost, not by raw block count.
|
||||
const normalizedLimit = Math.max(0, visibleLimit);
|
||||
const restingBlocks = styled.filter((b) => b._opacity === '1');
|
||||
let shownRestingBlocks = fitNewestBySquares(restingBlocks, normalizedLimit);
|
||||
const enteringBlock =
|
||||
enteringInRangeId === null ? undefined : restingBlocks.find((b) => b.id === enteringInRangeId);
|
||||
|
||||
if (enteringBlock && !shownRestingBlocks.some((b) => b.id === enteringBlock.id)) {
|
||||
// Guarantee the just-completed block a slot, then fill the remaining
|
||||
// square budget with the newest of the others.
|
||||
const reservedBudget = Math.max(0, normalizedLimit - squareCount(enteringBlock));
|
||||
const others = restingBlocks.filter((b) => b.id !== enteringBlock.id);
|
||||
shownRestingBlocks = [enteringBlock, ...fitNewestBySquares(others, reservedBudget)];
|
||||
}
|
||||
|
||||
const hiddenCount = Math.max(0, totalSquares(restingBlocks) - totalSquares(shownRestingBlocks));
|
||||
const usedSquares = totalSquares(shownRestingBlocks);
|
||||
const remainingBudget = Math.max(0, normalizedLimit - usedSquares);
|
||||
const transitioningBlocks =
|
||||
remainingBudget > 0
|
||||
? fitNewestBySquares(
|
||||
styled.filter((b) => b._opacity !== '1'),
|
||||
remainingBudget,
|
||||
)
|
||||
: [];
|
||||
const shownIds = new Set([
|
||||
...shownRestingBlocks.map((b) => b.id),
|
||||
...transitioningBlocks.map((b) => b.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
hiddenCount,
|
||||
visibleStyled: styled.filter((b) => shownIds.has(b.id)),
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'lt-tower',
|
||||
standalone: true,
|
||||
imports: [BlockComponent, TasksComponent, ModalComponent, TowerSettingsComponent, BlockEditComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="tower">
|
||||
<div #towerRoot class="tower">
|
||||
<div class="tower-header">
|
||||
<input
|
||||
#nameInput
|
||||
|
|
@ -76,26 +152,31 @@ const BLOCKS_PER_ROW = 6;
|
|||
<div
|
||||
#stackZone
|
||||
class="stack-zone"
|
||||
[class.empty]="tower().blocks.length === 0"
|
||||
[style.--block-stack-height]="blockStackHeight()"
|
||||
>
|
||||
<img
|
||||
src="assets/plus-sign.svg"
|
||||
class="add-block"
|
||||
alt="Add block"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation(); openAddBlock()"
|
||||
(keydown.enter)="$event.stopPropagation(); openAddBlock()"
|
||||
(keydown.space)="$event.preventDefault(); $event.stopPropagation(); openAddBlock()"
|
||||
/>
|
||||
|
||||
<div class="block-container-container">
|
||||
<div class="block-container">
|
||||
@for (b of visibleBlocks(); track b.id) {
|
||||
@for (sq of squares(); track sq.key) {
|
||||
<lt-block
|
||||
[block]="b"
|
||||
[block]="sq.block"
|
||||
[baseColor]="tower().base_color"
|
||||
[class]="b._anim"
|
||||
[style.transform]="b._transform"
|
||||
[style.opacity]="b._opacity"
|
||||
(clicked)="onEditBlock(b)"
|
||||
[class]="sq.block._anim"
|
||||
[style.transform]="sq.block._transform"
|
||||
[style.opacity]="sq.block._opacity"
|
||||
(clicked)="onEditBlock(sq.block)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -209,6 +290,7 @@ const BLOCKS_PER_ROW = 6;
|
|||
--block-stack-height: 0px;
|
||||
--add-block-size: 48px;
|
||||
--add-block-clearance: var(--medium-padding);
|
||||
--empty-add-block-center-offset: 0px;
|
||||
|
||||
@include card();
|
||||
overflow: hidden;
|
||||
|
|
@ -297,6 +379,16 @@ const BLOCKS_PER_ROW = 6;
|
|||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&.empty img.add-block {
|
||||
top: max(
|
||||
0px,
|
||||
min(
|
||||
calc(50% - var(--add-block-size) / 2 - var(--empty-add-block-center-offset)),
|
||||
calc(100% - var(--add-block-size))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.block-container-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
|
@ -431,16 +523,18 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
readonly dateRange = input<{ from: number; to: number } | null>(null);
|
||||
/** When true, the tasks accordion starts expanded on load. */
|
||||
readonly keepTasksOpen = input<boolean>(false);
|
||||
/** When true, completed blocks descend on this tower's first measured render. */
|
||||
readonly animateInitialStack = input<boolean>(false);
|
||||
|
||||
// ── Outputs ────────────────────────────────────────────────────────────────
|
||||
readonly updateTower = output<TowerSettingsResult>();
|
||||
readonly deleteTowerRequest = output<void>();
|
||||
/** Emitted when a new block is created from the carousel's "Create now" card. */
|
||||
readonly addBlock = output<{ tag: string; description: string; is_done: boolean }>();
|
||||
readonly addBlock = output<{ tag: string; description: string; is_done: boolean; difficulty: number }>();
|
||||
/** Emitted when an existing block is patched from the carousel. */
|
||||
readonly saveBlock = output<{
|
||||
blockId: string;
|
||||
result: { tag: string; description: string; is_done: boolean };
|
||||
result: { tag: string; description: string; is_done: boolean; difficulty: number };
|
||||
}>();
|
||||
readonly deleteBlock = output<string>();
|
||||
|
||||
|
|
@ -452,12 +546,13 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
readonly hiddenBlockCount = signal(0);
|
||||
|
||||
private readonly stackZone = viewChild<ElementRef<HTMLElement>>('stackZone');
|
||||
private readonly towerRoot = viewChild<ElementRef<HTMLElement>>('towerRoot');
|
||||
private readonly maxVisibleBlocks = signal<number | null>(null);
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
/** Pending (not-done) blocks — fed to the tasks accordion. */
|
||||
readonly pending = computed(() => this.tower().blocks.filter(b => !b.is_done));
|
||||
readonly pending = computed(() => this.tower().blocks.filter((b) => !b.is_done));
|
||||
|
||||
/** CSS color string for the tower name input. */
|
||||
readonly towerNameCss = computed(() => toCss(this.tower().base_color));
|
||||
|
|
@ -467,7 +562,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
const entry = this.editEntry();
|
||||
if (!entry) return [];
|
||||
const isDone = entry.filter === 'done';
|
||||
return this.tower().blocks.filter(b => b.is_done === isDone);
|
||||
return this.tower().blocks.filter((b) => b.is_done === isDone);
|
||||
});
|
||||
|
||||
readonly editViewTitle = computed(() => {
|
||||
|
|
@ -492,8 +587,23 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
private readonly _visibleBlocks = signal<StyledBlock[]>([]);
|
||||
readonly visibleBlocks = this._visibleBlocks.asReadonly();
|
||||
|
||||
/** Flat list of squares to render: each visible block expands into
|
||||
* `difficulty` adjacent squares that wrap (via the flex container) into
|
||||
* the row above. All squares of a block share its animation state. */
|
||||
readonly squares = computed<RenderSquare[]>(() => {
|
||||
const out: RenderSquare[] = [];
|
||||
for (const b of this.visibleBlocks()) {
|
||||
const n = squareCount(b);
|
||||
for (let k = 0; k < n; k++) {
|
||||
out.push({ key: `${b.id}#${k}`, block: b });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
readonly blockStackHeight = computed(() => {
|
||||
const rows = Math.ceil(this.visibleBlocks().length / BLOCKS_PER_ROW);
|
||||
const rows = Math.ceil(this.squares().length / BLOCKS_PER_ROW);
|
||||
const cqw = rows * (100 / BLOCKS_PER_ROW);
|
||||
return rows === 0 ? '0px' : `${Number(cqw.toFixed(4))}cqw`;
|
||||
});
|
||||
|
|
@ -505,10 +615,11 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
effect(() => {
|
||||
const range = this.dateRange();
|
||||
const maxVisibleBlocks = this.maxVisibleBlocks();
|
||||
const animateInitialStack = this.animateInitialStack();
|
||||
// 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, maxVisibleBlocks));
|
||||
untracked(() => this.reconcile(allDone, range, maxVisibleBlocks, animateInitialStack));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -545,6 +656,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
const styles = getComputedStyle(stackZone);
|
||||
const addBlockSize = this.parseCssPixels(styles.getPropertyValue('--add-block-size'), 48);
|
||||
this.measureEmptyAddBlockOffset(stackZone);
|
||||
const fallbackClearance = addBlockSize <= 32 ? 7.5 : 15;
|
||||
const clearance = this.parseCssPixels(
|
||||
styles.getPropertyValue('--add-block-clearance'),
|
||||
|
|
@ -557,6 +669,18 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
this.maxVisibleBlocks.set(Math.max(0, rows) * BLOCKS_PER_ROW);
|
||||
}
|
||||
|
||||
private measureEmptyAddBlockOffset(stackZone: HTMLElement): void {
|
||||
const towerRoot = this.towerRoot()?.nativeElement;
|
||||
if (!towerRoot) return;
|
||||
|
||||
const stackRect = stackZone.getBoundingClientRect();
|
||||
const towerRect = towerRoot.getBoundingClientRect();
|
||||
const stackCenter = stackRect.top + stackRect.height / 2;
|
||||
const towerCenter = towerRect.top + towerRect.height / 2;
|
||||
const offset = Math.max(0, stackCenter - towerCenter);
|
||||
stackZone.style.setProperty('--empty-add-block-center-offset', `${offset}px`);
|
||||
}
|
||||
|
||||
private parseCssPixels(value: string, fallback: number): number {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
|
|
@ -566,16 +690,23 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
allDone: Block[],
|
||||
range: { from: number; to: number } | null,
|
||||
maxVisibleBlocks: number | null,
|
||||
animateInitialStack: boolean,
|
||||
): void {
|
||||
if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) {
|
||||
this._visibleBlocks.set([]);
|
||||
this.hiddenBlockCount.set(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = allDone.map((b) => b.id);
|
||||
const prev = this.prevDoneIds;
|
||||
const prevSet = new Set(prev);
|
||||
const newIds = ids.filter(id => !prevSet.has(id));
|
||||
const newIds = ids.filter((id) => !prevSet.has(id));
|
||||
const grewByOne =
|
||||
!this.isFirstRun &&
|
||||
ids.length === prev.length + 1 &&
|
||||
newIds.length === 1 &&
|
||||
prev.every(id => ids.includes(id)); // no IDs disappeared
|
||||
prev.every((id) => ids.includes(id)); // no IDs disappeared
|
||||
|
||||
const inRange = (b: Block) =>
|
||||
!range || (b.created_at >= range.from && b.created_at <= range.to);
|
||||
|
|
@ -605,47 +736,37 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
const newInRangeId = grewByOne ? newIds[0] : null;
|
||||
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;
|
||||
let hiddenCount = 0;
|
||||
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));
|
||||
({ visibleStyled, hiddenCount } = selectVisibleStyledBlocks(
|
||||
styled,
|
||||
visibleLimit,
|
||||
newInRangeId,
|
||||
));
|
||||
}
|
||||
this.hiddenBlockCount.set(hiddenCount);
|
||||
|
||||
if (this.isFirstRun && animateInitialStack) {
|
||||
const initialBlocks = visibleStyled.filter((b) => b._opacity === '1' && inRange(b));
|
||||
if (initialBlocks.length > 0) {
|
||||
this.startDescendAnimation(visibleStyled, initialBlocks);
|
||||
this.prevDoneIds = ids;
|
||||
this.isFirstRun = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (grewByOne) {
|
||||
const newId = newIds[0];
|
||||
const newBlock = visibleStyled.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(visibleStyled);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
newBlock._anim = 'descend';
|
||||
newBlock._transform = 'translateY(0)';
|
||||
newBlock._opacity = '1';
|
||||
this._visibleBlocks.set([...this._visibleBlocks()]);
|
||||
});
|
||||
});
|
||||
this.startDescendAnimation(visibleStyled, [newBlock]);
|
||||
this.prevDoneIds = ids;
|
||||
this.isFirstRun = false;
|
||||
return;
|
||||
|
|
@ -658,6 +779,25 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
this.isFirstRun = false;
|
||||
}
|
||||
|
||||
private startDescendAnimation(visibleStyled: StyledBlock[], blocks: StyledBlock[]): void {
|
||||
for (const block of blocks) {
|
||||
block._anim = '';
|
||||
block._transform = 'translateY(500%)';
|
||||
block._opacity = '0';
|
||||
}
|
||||
this._visibleBlocks.set(visibleStyled);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
for (const block of blocks) {
|
||||
block._anim = 'descend';
|
||||
block._transform = 'translateY(0)';
|
||||
block._opacity = '1';
|
||||
}
|
||||
this._visibleBlocks.set([...this._visibleBlocks()]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
onRename(event: Event): void {
|
||||
|
|
@ -676,7 +816,12 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
onMarkTaskDone(block: Block): void {
|
||||
this.saveBlock.emit({
|
||||
blockId: block.id,
|
||||
result: { tag: block.tag, description: block.description, is_done: true },
|
||||
result: {
|
||||
tag: block.tag,
|
||||
description: block.description,
|
||||
is_done: true,
|
||||
difficulty: block.difficulty,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -691,11 +836,21 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
onBlockSave(ev: BlockEditSave): void {
|
||||
if (ev.id === null) {
|
||||
this.addBlock.emit({ tag: ev.tag, description: ev.description, is_done: ev.is_done });
|
||||
this.addBlock.emit({
|
||||
tag: ev.tag,
|
||||
description: ev.description,
|
||||
is_done: ev.is_done,
|
||||
difficulty: ev.difficulty,
|
||||
});
|
||||
} else {
|
||||
this.saveBlock.emit({
|
||||
blockId: ev.id,
|
||||
result: { tag: ev.tag, description: ev.description, is_done: ev.is_done },
|
||||
result: {
|
||||
tag: ev.tag,
|
||||
description: ev.description,
|
||||
is_done: ev.is_done,
|
||||
difficulty: ev.difficulty,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
frontend/src/app/components/tower/tower.component.vitest.ts
Normal file
95
frontend/src/app/components/tower/tower.component.vitest.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { StyledBlock } from './tower.component';
|
||||
import { selectVisibleStyledBlocks } from './tower.component';
|
||||
|
||||
function block(id: string, opacity = '1', difficulty = 1): StyledBlock {
|
||||
return {
|
||||
id,
|
||||
tag: 'tag',
|
||||
description: id,
|
||||
is_done: true,
|
||||
difficulty,
|
||||
created_at: 1,
|
||||
_anim: '',
|
||||
_transform: opacity === '1' ? 'translateY(0)' : 'translateY(500%)',
|
||||
_opacity: opacity,
|
||||
};
|
||||
}
|
||||
|
||||
describe('selectVisibleStyledBlocks', () => {
|
||||
it('reserves a capped visible slot for the newly completed block', () => {
|
||||
const styled = [
|
||||
block('newly-done'),
|
||||
block('done-1'),
|
||||
block('done-2'),
|
||||
block('done-3'),
|
||||
block('done-4'),
|
||||
block('done-5'),
|
||||
block('done-6'),
|
||||
block('done-7'),
|
||||
];
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 6, 'newly-done');
|
||||
|
||||
expect(result.hiddenCount).toBe(2);
|
||||
expect(result.visibleStyled.map((b) => b.id)).toEqual([
|
||||
'newly-done',
|
||||
'done-3',
|
||||
'done-4',
|
||||
'done-5',
|
||||
'done-6',
|
||||
'done-7',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the normal capped window when no new block is entering', () => {
|
||||
const styled = [
|
||||
block('done-0'),
|
||||
block('done-1'),
|
||||
block('done-2'),
|
||||
block('done-3'),
|
||||
block('done-4'),
|
||||
block('done-5'),
|
||||
block('done-6'),
|
||||
block('done-7'),
|
||||
];
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 6, null);
|
||||
|
||||
expect(result.hiddenCount).toBe(2);
|
||||
expect(result.visibleStyled.map((b) => b.id)).toEqual([
|
||||
'done-2',
|
||||
'done-3',
|
||||
'done-4',
|
||||
'done-5',
|
||||
'done-6',
|
||||
'done-7',
|
||||
]);
|
||||
});
|
||||
|
||||
it('caps by square cost (difficulty), not by raw block count', () => {
|
||||
// Each block draws 2 squares, so only 3 blocks fit in 6 square slots.
|
||||
const hard = (id: string): StyledBlock => block(id, '1', 2);
|
||||
const styled = [hard('a'), hard('b'), hard('c'), hard('d'), hard('e')];
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 6, null);
|
||||
|
||||
expect(result.visibleStyled.map((b) => b.id)).toEqual(['c', 'd', 'e']);
|
||||
expect(result.hiddenCount).toBe(4);
|
||||
});
|
||||
|
||||
it('counts hidden squares when reserving the entering block', () => {
|
||||
const styled = [
|
||||
block('newly-done', '1', 3),
|
||||
block('done-1', '1', 2),
|
||||
block('done-2', '1', 2),
|
||||
block('done-3', '1', 2),
|
||||
block('done-4', '1', 2),
|
||||
];
|
||||
|
||||
const result = selectVisibleStyledBlocks(styled, 6, 'newly-done');
|
||||
|
||||
expect(result.hiddenCount).toBe(6);
|
||||
expect(result.visibleStyled.map((b) => b.id)).toEqual(['newly-done', 'done-4']);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,31 +1,106 @@
|
|||
import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
||||
import { A11yModule } from '@angular/cdk/a11y';
|
||||
|
||||
@Component({
|
||||
selector: 'lt-welcome',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [A11yModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="welcome-card">
|
||||
<button class="exit" type="button" (click)="close.emit()" aria-label="Close"></button>
|
||||
<h2 id="welcome-title" tabindex="-1" cdkFocusInitial>Welcome to Life Towers</h2>
|
||||
|
||||
<h2>Welcome to Life Towers</h2>
|
||||
<button
|
||||
class="exit"
|
||||
type="button"
|
||||
(click)="close.emit()"
|
||||
aria-label="Dismiss welcome"
|
||||
></button>
|
||||
|
||||
<p class="lead">
|
||||
A visual TODO with bite. Each <strong>page</strong> is a context (work, hobbies, a project).
|
||||
Each <strong>tower</strong> is a stack of related tasks. As you finish a task, it falls into the
|
||||
tower as a colored square — the more you do, the taller it grows.
|
||||
<p class="lead" id="welcome-description">
|
||||
Life Towers turns completed tasks into visible stacks. Create pages for work, hobbies,
|
||||
home, or any project. Add towers for task groups, then check off tasks to build them
|
||||
block by block.
|
||||
</p>
|
||||
|
||||
<p class="muted">
|
||||
Everything you write is saved to a small remote database, keyed to a private UUID token shown
|
||||
under <em>Settings → Account</em>. Copy that token to recover your data on another device, or
|
||||
paste a friend's token to look at theirs.
|
||||
<p class="sr-only">
|
||||
Preview showing three towers with pending task bars at the top and completed task
|
||||
blocks stacked below.
|
||||
</p>
|
||||
<div class="tower-preview" aria-hidden="true">
|
||||
<div class="preview-shell">
|
||||
<div class="preview-page-tab"></div>
|
||||
<div class="preview-towers">
|
||||
<div class="preview-tower preview-tower--reading">
|
||||
<span class="preview-task"></span>
|
||||
<div class="preview-stack">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-tower preview-tower--projects">
|
||||
<span class="preview-task"></span>
|
||||
<span class="preview-task preview-task--short"></span>
|
||||
<div class="preview-stack">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-tower preview-tower--exercise">
|
||||
<span class="preview-task"></span>
|
||||
<div class="preview-stack">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="sr-only" id="welcome-basics-title">How Life Towers works</h3>
|
||||
<dl class="basics" aria-labelledby="welcome-basics-title">
|
||||
<div class="basic">
|
||||
<dt class="basic__label">Pages</dt>
|
||||
<dd class="basic__text">Keep each area separate.</dd>
|
||||
</div>
|
||||
<div class="basic">
|
||||
<dt class="basic__label">Towers</dt>
|
||||
<dd class="basic__text">Stack related tasks together.</dd>
|
||||
</div>
|
||||
<div class="basic">
|
||||
<dt class="basic__label">Blocks</dt>
|
||||
<dd class="basic__text">Finished tasks become colored blocks.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="account-note">
|
||||
Your towers save with a private recovery token in Settings, then Account. Use it to
|
||||
restore your workspace on another device.
|
||||
</p>
|
||||
|
||||
<p class="choice-note">
|
||||
Start empty if you know what to track, or load sample towers you can edit or delete.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" (click)="startFresh.emit()">Start fresh</button>
|
||||
<button type="button" class="primary" (click)="loadExample.emit()">Try an example</button>
|
||||
<button type="button" (click)="startFresh.emit()">Start empty</button>
|
||||
<button type="button" class="primary" (click)="loadExample.emit()">Load sample towers</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
|
@ -34,12 +109,27 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
|||
|
||||
:host { display: block; }
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 480px;
|
||||
width: min(560px, calc(100vw - (2 * var(--large-padding))));
|
||||
max-width: 560px;
|
||||
max-height: calc(100svh - (2 * var(--medium-padding)));
|
||||
overflow-y: auto;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
width: min(88vw, calc(100vw - (2 * var(--medium-padding))));
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
|
|
@ -48,8 +138,20 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
|||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
text-align: left;
|
||||
font-family: $normal-font;
|
||||
font-weight: 300;
|
||||
font-size: var(--medium-font-size);
|
||||
line-height: 1.45;
|
||||
@include inner-spacing(var(--medium-padding));
|
||||
|
||||
h2,
|
||||
h3,
|
||||
p,
|
||||
dl,
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
top: var(--medium-padding);
|
||||
|
|
@ -58,31 +160,231 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
|||
}
|
||||
|
||||
h2 {
|
||||
font-family: $title-font;
|
||||
font-weight: 400;
|
||||
font-size: var(--larger-font-size);
|
||||
text-align: center;
|
||||
margin-bottom: var(--medium-padding);
|
||||
padding: 0 36px;
|
||||
line-height: 1.25;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
padding: 0 28px;
|
||||
}
|
||||
}
|
||||
|
||||
p.lead { color: $text-color; }
|
||||
.lead {
|
||||
color: $text-color;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
max-width: 46ch;
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p.muted {
|
||||
color: rgba($text-color, 0.7);
|
||||
font-size: var(--medium-font-size);
|
||||
em { font-style: italic; }
|
||||
.tower-preview {
|
||||
box-sizing: border-box;
|
||||
padding: var(--medium-padding) 0;
|
||||
border-top: 1px solid rgba($text-color, 0.08);
|
||||
border-bottom: 1px solid rgba($text-color, 0.08);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
padding-block: var(--small-padding);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-shell {
|
||||
width: min(100%, 370px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.preview-page-tab {
|
||||
width: 96px;
|
||||
height: 14px;
|
||||
margin: 0 auto var(--small-padding);
|
||||
border-radius: var(--border-radius);
|
||||
background: rgba($text-color, 0.08);
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
|
||||
.preview-towers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
gap: var(--small-padding);
|
||||
}
|
||||
|
||||
.preview-tower {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 128px;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--border-radius);
|
||||
background: rgba($text-color, 0.025);
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
|
||||
.preview-task {
|
||||
display: block;
|
||||
width: 74%;
|
||||
height: 5px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--task-color);
|
||||
}
|
||||
|
||||
.preview-task--short {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
.preview-stack {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-content: end;
|
||||
gap: 2px;
|
||||
min-height: 86px;
|
||||
margin-top: auto;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 2px;
|
||||
background: var(--block-color);
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
|
||||
span:nth-child(2n) {
|
||||
filter: saturate(0.9) brightness(1.06);
|
||||
}
|
||||
|
||||
span:nth-child(3n) {
|
||||
filter: saturate(1.08) brightness(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@keyframes preview-task-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.42;
|
||||
transform: scaleX(0.86);
|
||||
}
|
||||
45%, 70% {
|
||||
opacity: 1;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes preview-block-fall {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-220%);
|
||||
}
|
||||
60%, 100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-task {
|
||||
transform-origin: left center;
|
||||
animation: preview-task-pulse 2600ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.preview-stack span {
|
||||
opacity: 0;
|
||||
animation: preview-block-fall 1200ms cubic-bezier(0.5, 0, 1, 0) forwards;
|
||||
animation-delay: calc(160ms + var(--fall-index, 0) * 95ms);
|
||||
}
|
||||
|
||||
.preview-stack span:nth-child(1) { --fall-index: 0; }
|
||||
.preview-stack span:nth-child(2) { --fall-index: 1; }
|
||||
.preview-stack span:nth-child(3) { --fall-index: 2; }
|
||||
.preview-stack span:nth-child(4) { --fall-index: 3; }
|
||||
.preview-stack span:nth-child(5) { --fall-index: 4; }
|
||||
.preview-stack span:nth-child(6) { --fall-index: 5; }
|
||||
.preview-stack span:nth-child(7) { --fall-index: 6; }
|
||||
.preview-stack span:nth-child(8) { --fall-index: 7; }
|
||||
.preview-stack span:nth-child(9) { --fall-index: 8; }
|
||||
}
|
||||
|
||||
.preview-tower--reading {
|
||||
--block-color: hsl(18, 70%, 58%);
|
||||
--task-color: hsla(18, 70%, 58%, 0.26);
|
||||
}
|
||||
|
||||
.preview-tower--projects {
|
||||
--block-color: hsl(209, 65%, 52%);
|
||||
--task-color: hsla(209, 65%, 52%, 0.24);
|
||||
}
|
||||
|
||||
.preview-tower--exercise {
|
||||
--block-color: hsl(130, 45%, 44%);
|
||||
--task-color: hsla(130, 45%, 44%, 0.22);
|
||||
}
|
||||
|
||||
.basics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--small-padding);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.basic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.basic__label {
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
color: $text-color;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.basic__text,
|
||||
.account-note,
|
||||
.choice-note {
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
color: rgba($text-color, 0.82);
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.account-note,
|
||||
.choice-note {
|
||||
max-width: 54ch;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.choice-note {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--large-padding);
|
||||
margin-top: var(--large-padding);
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
line-height: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue