life-towers/docs/DESIGN.md
2026-05-28 21:24:47 +01:00

26 KiB
Raw Blame History

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.

  • 7f hex alpha in $background-gradient is ~50% opacity. Opaque variant is used on <body>; 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:

<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 → tower transition: transform 250ms cubic-bezier(0,0,0.2,1).
  • .cdk-drag-placeholderopacity: 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 <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: .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 <meta name="theme-color" content="#5d576b" />.
  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).