snapshot
This commit is contained in:
parent
3ad2766f82
commit
f74ee43cb4
196 changed files with 18949 additions and 32173 deletions
795
docs/DESIGN.md
Normal file
795
docs/DESIGN.md
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
# 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`: `<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`:
|
||||
```html
|
||||
<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`:
|
||||
```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 `<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).
|
||||
|
||||
```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 `<input type="text">` 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
|
||||
<div [ngStyle]="{ 'background-color': block.color | color }" (click)="handleClick()"></div>
|
||||
```
|
||||
|
||||
```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: `<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`
|
||||
|
||||
```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 `<p>` 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 `<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-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 `<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).
|
||||
127
docs/api-spec.md
Normal file
127
docs/api-spec.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Life Towers API specification
|
||||
|
||||
This is the single source of truth for the v4 HTTP API between the Angular SPA and the FastAPI backend. Both clients and the server MUST conform to the shapes and rules defined here.
|
||||
|
||||
## Conventions
|
||||
|
||||
- All IDs are UUIDv4 strings (lowercase, canonical hex with dashes).
|
||||
- All timestamps are Unix epoch seconds as integers.
|
||||
- All requests and responses are `application/json` unless noted.
|
||||
- Auth is `Authorization: Bearer <token>` where `<token>` is a UUIDv4 generated client-side at first launch.
|
||||
- Same-origin: the frontend is served by the same FastAPI process, so CORS is locked to the deployment origin (or fully disabled in same-origin mode).
|
||||
- All payloads are size-capped at **2 MiB**. Server returns `413 Payload Too Large` on overflow.
|
||||
- Because `PUT /api/v1/data` atomically replaces the user's entire tree, the request size **is** the user's total storage — there is no separate per-user quota.
|
||||
- Every response carries an `X-Request-Id` (UUIDv4) for log correlation.
|
||||
|
||||
## Authentication
|
||||
|
||||
A "user" is identified solely by a token (UUIDv4). There is no password, email, or recovery mechanism. The token is the credential — losing it means losing the data.
|
||||
|
||||
- The token is generated on the client at first launch (`crypto.randomUUID()`).
|
||||
- The client calls `POST /api/v1/register` to claim the token. Idempotent — if the token already exists the server returns `200 OK` with the existing record.
|
||||
- All authenticated endpoints require `Authorization: Bearer <token>`. Missing/malformed → `401`. Token not a valid UUIDv4 → `401`. Token not in DB → `401`. The `detail` string is identical across all 401 causes so the response cannot be used to enumerate tokens.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /api/v1/health`
|
||||
|
||||
Public liveness probe. Returns `200 {"status":"ok"}`. No auth.
|
||||
|
||||
### `POST /api/v1/register`
|
||||
|
||||
Body: `{"token": "<uuidv4>"}`. Creates the user if absent; updates `last_seen_at` if present. Returns `200 {"user_id": "<uuidv4>"}`.
|
||||
|
||||
Rate limit: **30 requests / hour / IP**.
|
||||
|
||||
### `GET /api/v1/data`
|
||||
|
||||
Returns the full hierarchy belonging to the authenticated user. Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "string",
|
||||
"hide_create_tower_button": false,
|
||||
"default_date_from": 1700000000, // or null
|
||||
"default_date_to": 1700090000, // or null
|
||||
"towers": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "string",
|
||||
"base_color": { "h": 0.5, "s": 0.8, "l": 0.6 },
|
||||
"blocks": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"tag": "string",
|
||||
"description": "string",
|
||||
"is_done": false,
|
||||
"created_at": 1700000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Empty user → `200 {"pages": []}`.
|
||||
|
||||
Rate limit: **60 / minute / token**.
|
||||
|
||||
### `PUT /api/v1/data`
|
||||
|
||||
Atomically replaces the entire user hierarchy with the request body. Request body has the same shape as the `GET /api/v1/data` response. Server enforces:
|
||||
|
||||
- Every `id` is a valid UUIDv4. The server does NOT enforce that IDs already exist — clients are free to mint new IDs. IDs MUST be unique within the request (no duplicates at any level).
|
||||
- All string fields are bounded:
|
||||
- `page.name`, `tower.name`, `block.tag`: ≤ 200 chars
|
||||
- `block.description`: ≤ 10 000 chars
|
||||
- Numeric bounds:
|
||||
- HSL components: `h ∈ [0,1]`, `s ∈ [0,1]`, `l ∈ [0,1]`
|
||||
- Page-level counts: ≤ 100 pages, ≤ 100 towers per page, ≤ 1000 blocks per tower
|
||||
- Total blocks across the user: ≤ 50 000
|
||||
- The whole replacement happens in a single SQLite transaction. Existing rows for the user are deleted and the new tree is inserted. The `users.last_seen_at` timestamp is updated.
|
||||
|
||||
Returns `204 No Content` on success.
|
||||
|
||||
Rate limit: **30 / minute / token**.
|
||||
|
||||
### Error responses
|
||||
|
||||
All error responses are JSON: `{"error": "code", "detail": "human-readable"}`. Codes the client must handle:
|
||||
|
||||
| HTTP | code | When |
|
||||
|------|-----------------------|---------------------------------------------------------|
|
||||
| 400 | `bad_request` | Malformed JSON, missing fields, validation failures |
|
||||
| 401 | `unauthorized` | Missing/invalid/unknown token |
|
||||
| 413 | `payload_too_large` | Request body > 2 MiB |
|
||||
| 429 | `rate_limited` | Rate limit exceeded. `Retry-After` header set |
|
||||
| 500 | `server_error` | Unexpected server failure. Body is generic, no stacktrace |
|
||||
|
||||
## SPA hosting
|
||||
|
||||
Any non-`/api/*` route is served from the static frontend build:
|
||||
|
||||
- `GET /` → `index.html`
|
||||
- `GET /assets/*`, `/favicon.ico`, `/manifest.webmanifest`, `/ngsw-worker.js`, hashed JS/CSS bundles → static file
|
||||
- `GET /<anything-else>` → `index.html` (SPA fallback for client-side routing)
|
||||
|
||||
Static files served with:
|
||||
- `Cache-Control: public, max-age=31536000, immutable` for hashed assets
|
||||
- `Cache-Control: no-cache` for `index.html`
|
||||
- gzip / brotli pre-compressed where available
|
||||
|
||||
## Removed since legacy
|
||||
|
||||
- `POST /` (replaced by `POST /api/v1/register`)
|
||||
- `POST /me` (the `track` endpoint — pure DOS vector, dropped entirely)
|
||||
- `GET /me/root`, `PUT /me/root` (folded into `GET/PUT /api/v1/data`)
|
||||
- `GET /me/<id>`, `POST /me/<id>` (per-object endpoints — replaced by tree-replace semantics)
|
||||
|
||||
## Future extensions (not in v1, but designed to allow)
|
||||
|
||||
- `GET /api/v1/data/stream` (Server-Sent Events) — push notifications of remote changes. Replaces polling for multi-device sync.
|
||||
- Signed share tokens for read-only sharing without giving away the full account token.
|
||||
Loading…
Add table
Add a link
Reference in a new issue