# DESIGN.md — Life Towers Legacy Visual Spec (Angular 7 → 21 port)
Forensic reference for restoring pixel-perfect parity. Every snippet is quoted verbatim from `_legacy_reference/frontend/src/`. File paths are absolute.
---
## 1. Color tokens
`_legacy_reference/frontend/src/library/common-variables.scss:1-9`:
```scss
$accent-color: #a2666f;
$text-color: #5d576b;
$light-color: #ffffff;
$background-gradient: linear-gradient(90deg, #fff9e07f 0, #ffd6d67f 100%);
$background-gradient-opaque: linear-gradient(90deg, #fffcf0 0, #ffebeb 100%);
$shadow: 0 0 1.5px 1.5px rgba(0, 0, 0, 0.1), 0 0 3px 2px rgba(0, 0, 0, 0.05);
$shadow-border: 0 0 0 0.75px rgba(0, 0, 0, 0.1);
```
`index.html:14`: `` — iOS/Android theme bar = `$text-color`.
- `7f` hex alpha in `$background-gradient` is ~50% opacity. Opaque variant is used on `
`; semi-transparent is the **modal backdrop**.
- `$shadow` is a layered "soft-glow border" — first ring 1.5px tight (10% black), second 3px diffuse (5% black). Reuse exactly.
- `$shadow-border` is a 0.75px hairline used in place of CSS `border:` everywhere.
---
## 2. Typography
Fonts loaded in `index.html:8-11, 17-18`:
```html
```
`common-variables.scss:11-12`:
```scss
$normal-font: 'Open Sans Condensed', sans-serif;
$title-font: 'Raleway', serif;
```
Only **Open Sans Condensed Light 300** and **Raleway 400** are actually used in the visual design.
`library/text.scss:3-58`:
```scss
:root {
--larger-font-size: 22px;
--large-font-size: 18px;
--medium-font-size: 16px;
--small-font-size: 11px;
@media (max-width: $mobile-width) { // 520px
--larger-font-size: 20px;
--large-font-size: 16px;
--medium-font-size: 14px;
--small-font-size: 10px;
}
}
@mixin title-text { font-family: $title-font; color: $text-color; font-size: var(--larger-font-size); user-select: none; }
@mixin sub-title-text { font-family: $title-font; color: $text-color; font-size: var(--medium-font-size); user-select: none; }
@mixin normal-text { font-family: $normal-font; color: $text-color; font-size: var(--larger-font-size); }
@mixin medium-text { font-family: $normal-font; color: $text-color; font-size: var(--medium-font-size); }
@mixin small-text { font-family: $normal-font; color: $text-color; font-size: var(--small-font-size); }
h1, h2, h3 { @include title-text(); }
p { @include normal-text(); }
```
---
## 3. Spacing tokens
`library/main.scss:8-22`:
```scss
:root {
--border-radius: 5px;
--large-padding: 30px;
--medium-padding: 15px;
--small-padding: 10px;
@media (max-width: $mobile-width) { // 520px
--border-radius: 3px;
--large-padding: 20px;
--medium-padding: 15px;
--small-padding: 7.5px;
}
}
```
Body padding is `var(--large-padding)` — 30px desktop / 20px mobile around everything.
Breakpoints:
```scss
$mobile-width: 520px;
$min-height: 400px;
```
---
## 4. Animations
`library/animations.scss:1-22` (full file):
```scss
@import 'common-variables';
$long-animation-time: 200ms;
$short-animation-time: 100ms;
@mixin gravitate {
cursor: pointer;
transition: box-shadow $long-animation-time, transform $long-animation-time;
&:hover {
box-shadow: $shadow;
transform: scale(1.1);
}
}
@mixin jump {
cursor: pointer;
transition: transform $long-animation-time;
&:hover { transform: translateY(-2px); }
}
```
### 4a. The "falling animation" (THE critical interaction)
A block transitions from above the tower top (`translateY(500%)`) down into its slot. The `.block-container` is `transform: scaleY(-1)` (flipped). Visually each new block drops from the top of the tower and lands on top of the previous ones. The ease curve `cubic-bezier(0.5, 0, 1, 0)` is a steep accelerating ease-in (gravity).
`tower.component.scss:115-140`:
```scss
.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;
position: absolute;
bottom: 0;
width: 100%;
transform: scaleY(-1);
* { 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;
}
}
}
```
Driver pattern (`tower.component.ts:67-98`):
```ts
const lastBlock = top(this.styledBlocks);
if (lastBlock) {
lastBlock.style = { transform: 'translateY(500%)', opacity: '0' };
setTimeout(() => {
this.makeBlockDescend(lastBlock);
this.changeDetection.markForCheck();
}, 0);
}
...
makeBlockDescend(block) { block.cssClass = 'descend'; block.style = { transform: 'translateY(0)', opacity: '1' }; }
makeBlockAscend(block) { block.cssClass = 'ascend'; block.style = { transform: 'translateY(500%)', opacity: '0' }; }
```
Sequence on add: place the new block at `translateY(500%)/opacity:0` synchronously, then on next tick apply `.descend` class + reset transform to `translateY(0)/opacity:1`. The 1.5s gravity cubic curve does the fall; opacity fades in over the first 500ms.
On ascend: same curve but the opacity delay is **1s** so the block stays visible for most of the upward flight, then fades just before leaving.
### 4b. Other timed transitions
- Modal backdrop opacity: `300ms`.
- Tower hover-shadow + scale: `gravitate()` mixin = 200ms.
- Tower drag-and-drop reflow: `transform 200ms cubic-bezier(0, 0, 0.2, 1)`.
- cdkDrag animating: `transform 250ms cubic-bezier(0, 0, 0.2, 1)`.
- Trash icon scale-in: `transform 200ms`.
- Toggle thumb slide: `box-shadow/left/transform 200ms`.
- Select-add dropdown: `transform 200ms translateY(-100%) → none`; background height 200ms.
- Button underline (`forms.scss:78`): `width 300ms`.
- Tasks accordion `.all-task`: `height 200ms`.
---
## 5. Mixins (verbatim, ready to port)
`library/utils.scss`:
```scss
@mixin inner-spacing($spacing, $horizontal: false) {
& > *:not(:last-child) {
@if $horizontal { margin-right: $spacing; }
@else { margin-bottom: $spacing; }
}
}
```
`library/spacing.scss` (despite filename, contains `square`):
```scss
@mixin square($size) {
width: $size;
height: $size;
}
```
`library/main.scss:24-43`:
```scss
@mixin card {
border-radius: var(--border-radius);
background-color: $light-color;
}
@mixin center-child {
display: flex;
justify-content: center;
align-items: center;
}
@mixin exit {
@include square(16px);
background: url('/assets/x-sign.svg') no-repeat center center;
background-size: 50% 50%;
box-sizing: content-box;
padding: 8px;
@include jump();
}
```
`library/forms.scss:1-85` — global form styling:
```scss
@import 'text';
@import 'animations';
textarea {
@include normal-text();
&:disabled { background-color: $light-color; }
display: block; width: 100%; height: 150px;
@media (max-width: $mobile-width) { height: 100px; }
resize: none; box-sizing: border-box; border: none;
}
input[type='text'] {
@include sub-title-text();
width: 100%; background: transparent; display: block; border: 0;
&::placeholder { color: inherit; opacity: 0.6; }
&:focus { box-shadow: 0 1px $text-color; }
}
button {
-webkit-appearance: none;
margin: 8px auto 0 auto;
user-select: none;
background: transparent;
border: 0;
@include medium-text();
font-size: var(--large-font-size);
$height: 2px;
cursor: pointer;
border-bottom: solid $height #5d576b55;
position: relative;
&:disabled {
color: #5d576b55;
border-bottom: solid $height #5d576b33;
cursor: not-allowed;
}
&:not(:disabled):hover { &:after { width: 100%; } }
&:after {
content: '';
width: 0; height: $height;
position: absolute; left: 0;
bottom: calc(-1 * #{$height});
background-color: $text-color;
transition: width 300ms;
}
}
label { display: none; }
```
Global root + scrollbar (`styles.scss` + `main.scss:45-68`):
```scss
* { margin: 0; padding: 0;
&:active, &:focus { outline: 0; }
&::selection { background: $text-color; color: $light-color; }
&::placeholder { user-select: none; }
}
html { height: 100%; background-color: $text-color; }
body { height: 100%; background: $background-gradient-opaque; text-align: center; padding: var(--large-padding); box-sizing: border-box; position: relative; }
*::-webkit-scrollbar { width: 4px; height: 4px; }
*::-webkit-scrollbar-track { box-shadow: $shadow-border; border-radius: var(--border-radius); }
*::-webkit-scrollbar-thumb { background-color: $text-color; border-radius: var(--border-radius); cursor: pointer; }
* { -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; }
```
`$line-height: 2px;` is declared in `styles.scss:3` and reused by exit-pen underline, slider track, etc.
---
## 6. Layout rules per component
### 6a. Pages (top page selector) — `pages.component`
There is **no traditional tab strip**. The "page picker" is the `` dropdown inside `.select-add-container` with `width: 250px; margin: auto`. It serves as both selector ("Add a new page…" placeholder) and editor (`editable=true` shows pen icon).
```scss
:host {
height: 100%;
display: flex; flex-direction: column; justify-content: space-between;
@include inner-spacing(var(--large-padding));
.select-add-container { width: 250px; margin: 0 auto; }
.page-container { flex: 1 0 auto; }
button {
transition: opacity $long-animation-time;
&.transparent { opacity: 0; }
}
}
```
Settings button at bottom fades to opacity 0 while a tower is dragging.
### 6b. Page (towers container) — `page.component`
Magic geometry:
```scss
.towers {
display: flex; justify-content: center;
width: 100%; margin: 0 auto;
flex: 1 0 auto;
transition: box-shadow $short-animation-time;
max-width: 800px;
&.cdk-drop-list-dragging {
*:not(.cdk-drag-placeholder) {
transition: transform $long-animation-time cubic-bezier(0, 0, 0.2, 1);
}
}
div { @include center-child(); // add-tower wrapper
img.add-tower {
height: 48px;
@media (max-width: $mobile-width) { height: 32px; }
opacity: 0.33;
transition: opacity $long-animation-time;
cursor: pointer;
&:hover { opacity: 1; }
}
}
& > * {
max-width: 200px;
box-sizing: content-box;
flex: 0 0 auto;
&:not(:nth-last-child(1)) {
margin-right: var(--medium-padding);
@media (max-width: $mobile-width) { margin-right: var(--small-padding); }
}
}
position: relative;
@for $i from 1 to 6 {
& > *:first-child:nth-last-child(#{$i}),
& > *:first-child:nth-last-child(#{$i}) ~ * {
width: calc((100% - (#{$i} - 1) * var(--medium-padding)) / #{$i});
@media (max-width: $mobile-width) {
width: calc((100% - (#{$i} - 1) * var(--small-padding)) / #{$i});
}
}
}
}
```
Max 5 towers per page. Each tower gets an equal column up to 200px wide.
Trash icon:
```scss
img.trash {
@include square(48px);
padding: 16px;
position: absolute;
z-index: 1500;
bottom: 8px; left: 50%;
margin: 0 !important;
transform: translateX(-50%) scale(0);
transition: transform $long-animation-time;
&.active { transform: translateX(-50%) scale(1); }
&:hover { transform: translateX(-50%) scale(1.1); }
}
```
### 6c. Tower — `tower.component`
Tower header: the `` for the tower name (font: `var(--small-font-size)`, centered, width 50% desktop). Color = tower's `baseColor` HSL.
Card body:
```scss
.container {
display: flex; flex-direction: column;
flex: 1 1 auto;
position: relative;
@include card();
overflow: hidden;
transition: transform $short-animation-time, box-shadow $long-animation-time;
@include inner-spacing(var(--medium-padding));
width: 100%;
:before { // red flash overlay during trash-highlight
content: '';
pointer-events: none;
position: absolute; z-index: 2;
left: 0; top: 0; width: 100%; height: 100%;
background-color: red;
opacity: 0;
border-radius: var(--border-radius);
transition: opacity $short-animation-time;
}
img { // the plus-sign button inside the tower
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; }
}
}
```
Hover shows `$shadow` above `$mobile-width`. `.trash-highlight` class shrinks to `scale(0.75)`, bumps `:before` to 0.5 opacity, hides the name input.
### 6d. Block — `block.component` ⭐ CRITICAL VISUAL DIVERGENCE
**A block is purely a colored square** — sized to 1/6th of the tower width. No tag label, no description, no done-state. The visual distinction is "it's IN THE TOWER" (done) vs "it's in the TASKS accordion" (pending).
```html
```
```scss
:host {
position: relative;
width: calc(100% / 6);
padding-bottom: calc(100% / 6); // forces aspect-ratio 1:1
div {
position: absolute;
width: 100%; height: 100%;
@include gravitate(); // hover shadow + scale 1.1
}
}
```
Per-block color (`model/tower.ts:52-54`):
```ts
getColorOfTag(tag: string): IColor {
return lighten((hash(tag) - 0.5) * 50, this.baseColor);
}
```
```ts
// utils/color.ts
export const lighten = (by: number, { h, s, l }: IColor): IColor => {
let newL = l + by;
if (newL > 100) newL = 100;
else if (newL < 0) newL = 0;
return { h, s, l: newL };
};
```
Deterministic hash of tag → `[0,1)`, centered at 0.5 → `[-0.5, +0.5)`, scaled ×50 → `[-25, +25)` lightness offset added to tower's HSL. **All blocks in a tower vary in lightness only**, around the tower's baseColor.
### 6e. Tasks (pending blocks accordion) — `tasks.component` ⭐ MISSING IN NEW APP
Tasks is **not** a sub-modal — it's an in-tower accordion listing **pending** (not-done) blocks. Sits ABOVE the falling-blocks area, inside each tower.
```scss
:host {
width: 100%; box-sizing: border-box;
position: relative; z-index: 100000;
.container {
@include card();
cursor: pointer;
transition: box-shadow $long-animation-time;
&.show-hover:hover { box-shadow: $shadow-border; }
padding: calc(var(--small-padding) / 2);
margin: calc(var(--small-padding) / 2);
max-height: 30vh;
overflow-y: auto;
.header { cursor: pointer; }
p { font-size: var(--medium-font-size); }
.all-task {
@include inner-spacing(var(--small-padding));
:first-child { margin-top: var(--small-padding); }
height: 0;
box-sizing: border-box;
transition: height $long-animation-time;
overflow-y: hidden;
.task-container {
display: flex; align-items: center;
&:hover p { @media (min-width: $mobile-width) { color: inherit !important; } }
div { // colored dot per task
flex: 0 0 auto;
margin: 0 calc(var(--small-padding) / 2) 0 0;
@include square(var(--small-padding));
@media (max-width: $mobile-width) { @include square(calc(var(--small-padding) / 2)); }
}
p {
white-space: nowrap; text-overflow: ellipsis; overflow-x: hidden;
text-align: left;
@media (max-width: $mobile-width) {
font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2);
}
position: relative;
}
}
}
}
}
```
Header: `N task(s)`. Click expands `.all-task` from `height: 0` to `scrollHeight` in 200ms. Each row: colored dot (size `var(--small-padding)`) + description with ellipsis. Text color is the block's color; hover resets to inherit.
### 6f. Modal shell — `modal.component`
```scss
section {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 10000;
@include center-child();
padding: var(--large-padding);
box-sizing: border-box;
background: $background-gradient; // semi-transparent warm gradient!
transition: opacity 300ms;
&:not(.active) { opacity: 0; pointer-events: none; }
button { margin-top: var(--medium-padding); }
}
```
Modal backdrop = the warm cream→pink gradient at 50% alpha layered over the app. **Distinctive.** Open transition opacity 0→1 in 300ms.
### 6g. Sub-modals (settings / remove-page / remove-tower / blocks-edit)
All small modals share the same card shell:
```scss
section / :host {
@include card();
width: 66vw;
max-width: 400px; // settings = 400, remove-* = 500
@media (max-width: $mobile-width) { width: 300px; }
box-sizing: border-box;
padding: var(--large-padding);
position: relative;
box-shadow: $shadow;
@include inner-spacing(var(--large-padding));
.header {
@include center-child();
.exit {
position: absolute;
left: var(--large-padding);
@include exit(); // x-sign.svg, 16px box, 8px padding, jump hover
}
}
}
```
Block-edit modal is a **horizontally scrolling carousel** of cards. Each card 66vw / max 400px. Two transparent spacer cards at start/end so the active card centers. `.mask` overlay fades opaque on inactive neighbours. Snap-to-center on scroll-end (150ms idle).
`get-started.component`: stub — skip in port.
### 6h. Toggle (custom switch)
A **dual-label switch**: left label + oval track + right label. Active-side label gets `font-weight: bold`. Hover nudges the thumb 2px toward the other side.
```scss
:host {
$size: 30px;
@include center-child();
@include inner-spacing(var(--medium-padding), $horizontal: true);
span {
@include medium-text();
max-width: 3 * $size;
cursor: pointer;
&.active { font-weight: bold; }
&:first-of-type { text-align: right; }
&:last-of-type { text-align: left; }
}
label { display: block;
input[type='checkbox'] {
-webkit-appearance: none; -moz-appearance: none;
width: 2 * $size; height: $size;
border-radius: 1000px;
box-shadow: $shadow-border;
position: relative;
cursor: pointer;
&:after {
content: ''; position: absolute; display: block;
left: 0;
@include square($size);
border-radius: 1000px;
background-color: $text-color;
transition: box-shadow $long-animation-time, left $long-animation-time, transform $long-animation-time;
}
&.on:after { left: $size; }
@media (min-width: $mobile-width) {
&:hover:after { box-shadow: $shadow; transform: translateX(2px); }
&.on:hover:after { transform: translateX(-2px); }
}
}
}
}
```
### 6i. Select-add — `select-add.component`
A custom dropdown that doubles as inline creator (with `+ Add` button) and optional inline editor (pen icon, `editable=true`).
Top bar = white card showing selected text + arrow. Click → `.bottom` slides via `transform: translateY(-100%) → none` over 200ms. Other options listed as `` rows. Bottom: text input + Add button + optional pen icon. Arrow rotates 180° when open.
```scss
.background {
position: absolute; top: 0;
height: 100%; width: 100%;
@include card();
z-index: 3;
transition: box-shadow $long-animation-time, height $long-animation-time;
&.active { box-shadow: $shadow; }
}
&:hover { @media (min-width: $mobile-width) { .background { box-shadow: $shadow; } } }
&.shadow-border { .background.active { box-shadow: $shadow-border; } }
```
Flags: `alwaysDropShadow` pre-applies open shadow. `onlyShadowBorder` swaps soft shadow for hairline (used inside block-edit modal).
### 6j. Double-slider (date-range — NOT an HSL picker)
CRITICAL CLARIFICATION: legacy `double-slider` is a **two-thumb date-range slider** filtering blocks by `created` date — not an HSL color picker. The HSL color picker for tower base-color doesn't exist in the legacy reference (the tower color was likely set elsewhere or hardcoded). This is what makes the page "beautiful": as a thumb approaches a date label, the label slides upward and rotates -45°, like magnetic markers.
```scss
$height: 70px;
$width: 300px;
$slider-size: 40px;
.container {
width: $width;
height: $height;
position: relative;
margin: $slider-size / 2 auto 0 auto;
label { display: none; }
input[type='range'] {
width: 100%;
position: absolute; left: 0;
-webkit-appearance: none; outline: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
height: $slider-size; // 40px
width: $slider-size;
border-radius: 1000px;
background-color: $light-color;
transform-origin: center center;
transform: translateY(-$slider-size / 2 + $line-height / 2);
transition: box-shadow $long-animation-time, transform $long-animation-time;
@media (min-width: $mobile-width) {
&:hover {
box-shadow: $shadow;
transform: translateY(-$slider-size / 2 + $line-height / 2) scale(1.1);
}
}
cursor: pointer;
position: relative; z-index: 2;
}
&::-webkit-slider-runnable-track {
-webkit-appearance: none;
width: 100%;
height: $line-height; // 2px
background-color: $text-color;
border-radius: 1000px;
}
}
.value-container {
@include small-text();
display: flex; justify-content: space-evenly;
span { display: block; margin-top: 10px; }
}
}
```
Two stacked `` on the same track. White 40px circular thumbs, 2px solid track. Hover scales thumb 1.1× and adds `$shadow`.
Labels: `getOffset(index)` for each of 6 evenly-spaced date labels, compute distance to nearer thumb (normalized `[0,1]`); within 0.2 "active zone" the label translates upward by `(1 - d/0.2) * 30px`, rotated -45°.
---
## 7. Drag-and-drop
Tower list is a `cdkDropList cdkDropListOrientation="horizontal"`. Each `` is `cdkDrag`. Cursor: `pointer` (not grab/grabbing).
- `.cdk-drag-animating` → tower `transition: transform 250ms cubic-bezier(0,0,0.2,1)`.
- `.cdk-drag-placeholder` → `opacity: 0`.
- `.cdk-drag-preview` → mobile fades `box-shadow` in via inline `@keyframes shadow` over 200ms.
- `.cdk-drop-list-dragging *:not(.cdk-drag-placeholder)` → `transition: transform 200ms cubic-bezier(0,0,0.2,1)`.
Trash interaction (`page.component.ts:69-91`):
- `pointerenter` on trash → `nearTrashcan=true`, append `' trash-highlight'` to `.cdk-drag-preview`'s className.
- `pointerleave` → remove class.
- `pointerup` on trash → open remove-tower confirm modal.
During drag:
- `isDragging` true → trash icon `.active` springs in.
- `isDragHappening` emitted up → Settings button fades to opacity 0.
- Date-slider container fades to opacity 0.
---
## 8. Image assets
All SVGs in `_legacy_reference/frontend/src/assets/`:
| File | Where used | Size | Behaviour |
|---|---|---|---|
| `arrow.svg` | `select-add` top bar | `square(16px)` | rotates `-180deg` open, transition 200ms |
| `pen.svg` | `select-add` edit button, blocks-modal edit | `square(16px)` in wrapper | `opacity: 0.25 → 0.5 (hover) → 1 (active)`; `:before` 2px underline expands 0→100% over 200ms |
| `plus-sign.svg` | Tower internal add-block, end-of-row add-tower | `height: 48px` desktop, `32px` mobile | `opacity: 0.33 → 1` (hover) over 200ms |
| `trash.svg` | Page absolute-positioned trash zone | `square(48px); padding: 16px` (80×80 hit box), `bottom: 8px; left: 50%` | `scale(0) → scale(1) (.active) → scale(1.1) (hover)`; `translateX(-50%)` preserved |
| `x-sign.svg` | All modal exit buttons | 16px inner, 8px padding, `background-size: 50% 50%` | `@include jump()` hover lift |
---
## 9. Per-state styling
### Block
- **Pending** (`!isDone`): appears **only in `` accordion** as a colored-dot row.
- **Done** (`isDone === true`): appears as a 1/6-tower-width colored square in the falling stack.
- **Filtered out by date slider**: `.ascend` class, transitions out over 1.5s (opacity delayed 1s).
- **Hover** (done block): `gravitate()` → `box-shadow: $shadow` + `transform: scale(1.1)` in 200ms.
### Tower
- **Idle**: white card.
- **Hover** (desktop): `box-shadow: $shadow` over 200ms.
- **Dragging preview**: mobile fades shadow in over 200ms.
- **Drag placeholder**: `opacity: 0`.
- **Over trash (`.trash-highlight`)**: `scale(0.75)`, red overlay at 0.5 opacity, name input hidden.
### Page-tab (select-add for pages)
No active/inactive — only "currently selected page" at top of dropdown.
- **Closed**: white card, hairline shadow on hover.
- **Open**: `$shadow` (or `$shadow-border` if `onlyShadowBorder`).
---
## Implementation order
1. Drop `library/*.scss` into `frontend/src/library/` verbatim. Update `styles.scss` to import them.
2. Apply global body/html/scrollbar styles.
3. Load Google Fonts (Open Sans Condensed 300 + Raleway). Drop the self-hosted-fonts I added if they're locked at incorrect weights — re-verify woff2 files cover the right weights and rewrite @font-face cleanly. Keep self-hosting if you want, just match the weights exactly.
4. Set ``.
5. Build `select-add`, `toggle`, `double-slider` first.
6. Build modal shell + sub-modals using the shared card recipe.
7. Build tower → block → tasks.
8. Build page with the `@for $i from 1 to 6` width calc and trash zone.
9. Wire the "falling animation" exactly per §4a — the `setTimeout(..., 0)` two-step is essential.
---
## Critical model recap for implementers
- **Block has a `tag`** (string) and **does NOT have a description** in the legacy UI. The "description" field in the new app is not in the legacy data model — the legacy block is just `{ id, tag, isDone, created }` plus optionally derived data. Verify against the legacy `block.ts` and `IBlock` interface. The NEW backend's normalized schema has a `description` — keep it, but make it OPTIONAL and don't render it as the block's primary visual.
- **Tower color picker**: NOT in the legacy reference. The new app's color-picker exists; align its visuals with the rest of the design (white card, $shadow, etc.) but don't pretend it matches a legacy that didn't exist.
- **Date-range filter**: the double-slider in the legacy filtered blocks by their `created` date. Decide whether to port this (recommended — it's the "beautiful slider" the user remembers).