26 KiB
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:
$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: <meta name="theme-color" content="#5d576b" /> — iOS/Android theme bar = $text-color.
7fhex alpha in$background-gradientis ~50% opacity. Opaque variant is used on<body>; semi-transparent is the modal backdrop.$shadowis a layered "soft-glow border" — first ring 1.5px tight (10% black), second 3px diffuse (5% black). Reuse exactly.$shadow-borderis a 0.75px hairline used in place of CSSborder:everywhere.
2. Typography
Fonts loaded in index.html:8-11, 17-18:
<link href="https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300|Raleway&display=swap&subset=latin-ext" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
common-variables.scss:11-12:
$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:
: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:
: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:
$mobile-width: 520px;
$min-height: 400px;
4. Animations
library/animations.scss:1-22 (full file):
@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:
.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):
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:
@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):
@mixin square($size) {
width: $size;
height: $size;
}
library/main.scss:24-43:
@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:
@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):
* { 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 <app-select-add> 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).
: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:
.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:
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 <input type="text"> for the tower name (font: var(--small-font-size), centered, width 50% desktop). Color = tower's baseColor HSL.
Card body:
.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).
<div [ngStyle]="{ 'background-color': block.color | color }" (click)="handleClick()"></div>
: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):
getColorOfTag(tag: string): IColor {
return lighten((hash(tag) - 0.5) * 50, this.baseColor);
}
// 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.
: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: <strong>N</strong> 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
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:
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.
: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 <p> rows. Bottom: text input + Add button + optional pen icon. Arrow rotates 180° when open.
.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.
$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 <input type="range"> 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 <app-tower> is cdkDrag. Cursor: pointer (not grab/grabbing).
.cdk-drag-animating→ towertransition: transform 250ms cubic-bezier(0,0,0.2,1)..cdk-drag-placeholder→opacity: 0..cdk-drag-preview→ mobile fadesbox-shadowin via inline@keyframes shadowover 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):
pointerenteron trash →nearTrashcan=true, append' trash-highlight'to.cdk-drag-preview's className.pointerleave→ remove class.pointerupon trash → open remove-tower confirm modal.
During drag:
isDraggingtrue → trash icon.activesprings in.isDragHappeningemitted 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<app-tasks>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:
.ascendclass, 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: $shadowover 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-borderifonlyShadowBorder).
Implementation order
- Drop
library/*.scssintofrontend/src/library/verbatim. Updatestyles.scssto import them. - Apply global body/html/scrollbar styles.
- 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.
- Set
<meta name="theme-color" content="#5d576b" />. - Build
select-add,toggle,double-sliderfirst. - Build modal shell + sub-modals using the shared card recipe.
- Build tower → block → tasks.
- Build page with the
@for $i from 1 to 6width calc and trash zone. - 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 legacyblock.tsandIBlockinterface. The NEW backend's normalized schema has adescription— 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
createddate. Decide whether to port this (recommended — it's the "beautiful slider" the user remembers).