docs: add CLAUDE.md agent guide, drop legacy design/API specs, refresh READMEs
This commit is contained in:
parent
f74ee43cb4
commit
afce46ccf8
5 changed files with 307 additions and 982 deletions
291
CLAUDE.md
Normal file
291
CLAUDE.md
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
# Life Towers — agent notes
|
||||
|
||||
This file captures the load-bearing things to know about this codebase. Read it before making non-trivial changes.
|
||||
|
||||
## What this is
|
||||
|
||||
A personal-productivity TODO app where each user has multiple **pages**, each page holds several **towers** (vertical task columns), and each tower has **blocks** (atomic tasks). Pending blocks live in a **tasks accordion** at the top of the tower; completed ones fall into the tower as colored squares (the "falling animation" is the defining visual). Date-range slider filters which done blocks are visible.
|
||||
|
||||
This is a port/modernisation of a legacy Angular 7 app. **Design parity with the legacy is a hard requirement** — the user cares deeply about the original aesthetic. The legacy source lives at `_legacy_reference/` (gitignored) and is the source of truth for any visual question. A detailed style guide is at `docs/DESIGN.md`.
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
backend/ # FastAPI + SQLite (WAL)
|
||||
src/life_towers/
|
||||
main.py # ASGI app, lifespan, static mount + SPA fallback
|
||||
api.py # Routes (/api/v1/{health,register,data})
|
||||
auth.py # Bearer UUIDv4 → user_id
|
||||
db.py # sqlite3 factory + migration runner
|
||||
limits.py # slowapi limiter + payload-size middleware
|
||||
models.py # pydantic v2 schemas
|
||||
logging.py # structlog JSON
|
||||
migrations/ # 001_initial.sql consolidated schema
|
||||
tests/test_api.py # pytest + httpx AsyncClient
|
||||
pyproject.toml # uv-managed
|
||||
frontend/ # Angular 21+ standalone, signals, zoneless
|
||||
src/app/
|
||||
components/
|
||||
pages/ # Top-level: page selector + Settings button
|
||||
page/ # Tower row + slider + trash zone + confirm-delete
|
||||
tower/ # White tower card: tasks accordion + add-block + falling stack + name input
|
||||
block/ # Colored square (1/6 tower width)
|
||||
tasks/ # Pending-blocks accordion with tickbox
|
||||
welcome/ # Zero-state intro modal + "Try an example"
|
||||
modal/ # Generic backdrop shell + sub-modals (block-edit carousel, settings, tower-settings)
|
||||
shared/ # select-add, toggle, double-slider, color-picker, icon
|
||||
services/
|
||||
api.service.ts # HttpClient wrapper, exact API contract
|
||||
store.service.ts # signal-based store, debounced PUT, retry, loadExample
|
||||
modal-state.service.ts # global open-modal counter (drag locking)
|
||||
models/index.ts # Page / Tower / Block / HslColor TS interfaces
|
||||
utils/{color,hash}.ts
|
||||
library/ # Legacy SCSS dropped in verbatim — DO NOT REFACTOR
|
||||
public/assets/ # SVG icons (arrow, pen, plus-sign, trash, x-sign)
|
||||
src/assets/fonts/ # Self-hosted woff2 (Open Sans Condensed, Raleway, …)
|
||||
e2e/ # Playwright (smoke + visuals)
|
||||
docs/
|
||||
api-spec.md # Single source of truth for the HTTP API contract
|
||||
DESIGN.md # Legacy visual spec (verbatim SCSS quotes)
|
||||
_legacy_reference/ # Original Angular 7 source — read-only, gitignored
|
||||
Dockerfile # Multi-stage: node:22-alpine build → python:3.13-slim runtime
|
||||
docker-compose.yml # Production single-container
|
||||
docker-compose.dev.yml # Ephemeral volume for Playwright runs
|
||||
.forgejo/workflows/ # CI + deploy
|
||||
```
|
||||
|
||||
## Tech stack quickrefs
|
||||
|
||||
- **Frontend**: Angular 21+, **standalone components** (no NgModule), `signal()` / `computed()` / `effect()` / `linkedSignal()`, **zoneless change detection** (`provideZonelessChangeDetection`), `@if`/`@for` control flow, **OnPush** everywhere, esbuild builder (`@angular/build:application`), Reactive Forms, Angular Service Worker (PWA), Angular CDK (drag-drop, A11yModule for focus trap)
|
||||
- **Backend**: FastAPI, pydantic v2, slowapi (rate limiting), structlog, **sqlite3 with WAL + foreign_keys ON** every connection, uv-managed deps
|
||||
- **Runtime topology**: single Docker container — FastAPI process serves both `/api/v1/*` JSON endpoints AND the built Angular SPA as static files with SPA fallback. Behind nginx; uvicorn launched with `--proxy-headers --forwarded-allow-ips=*`
|
||||
- **Storage**: SQLite at `/data/life-towers.db` on a named Docker volume. Tree-replace semantics (PUT replaces user's full tree atomically inside `BEGIN IMMEDIATE`)
|
||||
|
||||
## Build / dev / test / deploy
|
||||
|
||||
```bash
|
||||
# Full local stack (ephemeral data)
|
||||
docker compose -f docker-compose.dev.yml up --build -d # http://localhost:8000
|
||||
docker compose -f docker-compose.dev.yml down -v # teardown
|
||||
|
||||
# Frontend dev (with backend proxied via proxy.conf.json)
|
||||
cd frontend && npm start # ng serve on :4200, /api → :8000
|
||||
cd frontend && npm run build # production bundle to dist/frontend/browser/
|
||||
cd frontend && npm test # vitest (--run already in the script)
|
||||
cd frontend && npm run test:e2e # Playwright
|
||||
|
||||
# Backend dev
|
||||
cd backend && uv sync
|
||||
cd backend && uv run pytest -v
|
||||
cd backend && uv run uvicorn life_towers.main:app --reload # :8000
|
||||
|
||||
# E2E in the sandbox: host-to-container port forwarding is broken here.
|
||||
# Run Playwright INSIDE a container on the docker network instead:
|
||||
docker run --rm \
|
||||
--network life-towers_default \
|
||||
-v "$(pwd)/frontend:/work" -w /work \
|
||||
-e PLAYWRIGHT_BASE_URL=http://life-towers:8000 \
|
||||
mcr.microsoft.com/playwright:v1.60.0-noble \
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
## Design system — the legacy is the source of truth
|
||||
|
||||
`docs/DESIGN.md` is the comprehensive spec. Key tokens:
|
||||
|
||||
```scss
|
||||
// _legacy_reference/frontend/src/library/common-variables.scss
|
||||
$accent-color: #a2666f; // rose
|
||||
$text-color: #5d576b; // muted purple-grey (also iOS theme-color)
|
||||
$light-color: #ffffff;
|
||||
$background-gradient: linear-gradient(90deg, #fff9e07f 0, #ffd6d67f 100%); // 50% alpha — modal backdrop
|
||||
$background-gradient-opaque: linear-gradient(90deg, #fffcf0 0, #ffebeb 100%); // body background
|
||||
$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); // hairline
|
||||
$normal-font: 'Open Sans Condensed', sans-serif;
|
||||
$title-font: 'Raleway', serif;
|
||||
$mobile-width: 520px;
|
||||
```
|
||||
|
||||
Spacing tokens (`library/main.scss`):
|
||||
```scss
|
||||
:root {
|
||||
--large-padding: 30px; // 20px on mobile
|
||||
--medium-padding: 15px;
|
||||
--small-padding: 10px; // 7.5px on mobile
|
||||
--border-radius: 5px; // 3px on mobile
|
||||
}
|
||||
```
|
||||
|
||||
Font sizes (`library/text.scss`): `--larger/large/medium/small-font-size` = `22/18/16/11` desktop, `20/16/14/10` mobile.
|
||||
|
||||
**Animation timings**:
|
||||
- `$long-animation-time: 200ms` — opacity, hover transforms, modal entry
|
||||
- `$short-animation-time: 100ms` — tighter transitions (red trash-highlight overlay)
|
||||
- **Falling animation**: `transform 1.5s cubic-bezier(0.5, 0, 1, 0)` (gravity ease-in)
|
||||
- **Modal opacity entry/exit**: `300ms`
|
||||
|
||||
## Architectural conventions to follow
|
||||
|
||||
### Components
|
||||
|
||||
- Every component is **standalone**, **OnPush**, with `signal()`-based local state. No NgModule.
|
||||
- Templates use **`@if` / `@for` / `@switch`** — never `*ngIf`/`*ngFor`.
|
||||
- Inline templates + inline styles in `@Component({ template: \`...\`, styles: \`...\` })` is the norm. Larger components use templateUrl + styleUrl (only `pages.component`, `page.component`).
|
||||
- SCSS inside `styles:` template literal — **`//` comments are fine inside SCSS but watch for backticks**; `// `display: contents`` inside a template literal closes it early. Use `/* */` if you need to mention CSS strings in comments.
|
||||
- Library files (`library/*.scss`) are dropped from the legacy verbatim. Don't refactor them. Components `@import '../../../library/main'` (or similar relative depth).
|
||||
|
||||
### State
|
||||
|
||||
- `StoreService` is the single source of truth (signal-based).
|
||||
- Mutations update the signal immediately (**optimistic**) and call `scheduleSave()` which debounces 750ms and PUTs the full tree.
|
||||
- Failure mode: exponential backoff up to 5 attempts (1s, 2s, 4s, 8s, 16s).
|
||||
- LocalStorage cache key: `life-towers.cache.v4`. Token: `life-towers.token.v4`.
|
||||
- `init()` flow: stored token? Use it. No token? Mint via `uuidV4()` → register → GET data. On 401: re-register the SAME token (idempotent server-side), never silently mint a fresh one (would orphan data).
|
||||
|
||||
### Reactivity caveats with zoneless
|
||||
|
||||
- Plain field mutations in event handlers (`(click)="x = true"`) still trigger CD because Angular's event manager marks the view dirty — even with zoneless. But any async mutation outside an Angular event (setTimeout, raw addEventListener, MutationObserver) **will not** trigger CD; use signals there.
|
||||
- `effect()` running on `signal()` reads triggers re-runs; wrap writes in `untracked()` to avoid loops.
|
||||
- For input-driven derived state that the user can also override (e.g. `tasks.expanded` seeded from `initiallyOpen` but also clickable), use either `linkedSignal` or `effect()` + a flag (the codebase uses the latter for `tasks.component`).
|
||||
|
||||
### Sync model
|
||||
|
||||
- **Tree-replace**: `PUT /api/v1/data` sends the entire user hierarchy atomically. Backend wraps in `BEGIN IMMEDIATE`, deletes existing pages (cascading to towers + blocks via FK), inserts new rows. `position` columns track ordering.
|
||||
- **No granular endpoints** for individual entity CRUD. Keep it tree-replace.
|
||||
- Spec is in `docs/api-spec.md`. Backend pydantic models match it. Frontend `models/index.ts` matches it. Field names are **snake_case** on both sides.
|
||||
|
||||
### Backend
|
||||
|
||||
- All endpoints are inside `APIRouter(prefix="/api/v1")`. Spec drives behavior — if you change a limit, update both spec and code.
|
||||
- Migrations: package data under `src/life_towers/migrations/`, loaded via `importlib.resources.files("life_towers").joinpath("migrations")`. The runner tracks applied state in a `schema_migrations(filename TEXT PRIMARY KEY, applied_at INTEGER)` table.
|
||||
- All sqlite connections must do `PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000`. The `get_connection()` factory does this.
|
||||
- Errors → JSON `{"error": code, "detail": str}` via a single `HTTPException` handler in `main.py`. Stack traces never leak (logged server-side).
|
||||
- Rate limits via slowapi: `/register` 30/hour/IP, `GET /data` 60/min/token, `PUT /data` 30/min/token.
|
||||
|
||||
## Visual + interaction details that bit me
|
||||
|
||||
### Falling animation (tower.component)
|
||||
|
||||
The `.block-container` has `transform: scaleY(-1)` so blocks visually fall from the TOP into the bottom of the tower. Each block default-positions at `translateY(500%)` via a `*` rule; the inline `[style.transform]="b._transform"` binding overrides per-block.
|
||||
|
||||
When exactly **one** new done block is added, the `reconcile()` method:
|
||||
1. Sets the new block's `_transform: 'translateY(500%)'` and `_opacity: '0'` (off-screen)
|
||||
2. Calls `requestAnimationFrame` → `requestAnimationFrame` to let the browser paint the initial state
|
||||
3. Sets `_anim: 'descend'`, `_transform: 'translateY(0)'`, `_opacity: '1'` — the CSS transition fires
|
||||
|
||||
**Critical**: `grewByOne` detection is position-independent (set-difference). When a tickbox flips a pending block to done, the new entry inserts at its original `tower.blocks` index, not appended. Use the new ID, not `styled[length-1]`.
|
||||
|
||||
The **date range** slider asymmetry: blocks below `range.from` are removed from `visibleBlocks` entirely (instant shuffle, no gap), blocks above `range.to` get `_anim: 'ascend'` and stay in the list flying up. `prevDoneIds` tracks the full `allDone` array — not the filtered styled list — so range expansions don't mis-fire as "new block".
|
||||
|
||||
### Block-edit carousel (modal/block-edit.component)
|
||||
|
||||
- Lives at `position: fixed; z-index: 10001` to escape the modal dialog wrapper and cover the viewport
|
||||
- Two placeholder cards flank the real cards so the active card can fully center via `scroll-snap-align: center`
|
||||
- `.mask` overlay on non-active cards has three tiers: `active opacity 0`, `near-active 0.55`, default `1`. Card opacity also tiered (1 / 0.85 / 0.6) mimicking the legacy `1.33*(1-t/2)` curve
|
||||
- Backdrop click (anywhere not a non-placeholder card) closes the modal
|
||||
- Delete on an existing card **does not** close the modal — it stays open and the card re-renders out of the list
|
||||
- Auto-save on tag/toggle change; description deferred to blur
|
||||
|
||||
### Select-add (shared/select-add)
|
||||
|
||||
- Has a `.top` chip and a `.bottom` slide-down panel. `:has(.bottom.open) .top, .background { border-radius: var(--border-radius) var(--border-radius) 0 0 }` squares the bottom corners when open so the chip and panel read as one card
|
||||
- Shadow seam between chip + panel solved with `clip-path: inset(...)`: `.background.active` clips bottom (`inset(-6px -6px 0 -6px)`), `.bottom.open` clips top (`inset(0 -6px -6px -6px)`). 6px > the total $shadow spread (5px) so neither edge bleeds across the seam
|
||||
- Closing animation requires a two-transition setup: default state has `transition: ... visibility 0s $long-animation-time` (visibility delays on close), `.open` overrides with `visibility 0s 0s` (instant on open)
|
||||
|
||||
### Modal shell (modal/modal.component)
|
||||
|
||||
- `:host { display: contents }` — critical. Without this, the `lt-modal` host element takes a flex slot in `pages.component`'s `inner-spacing` layout, pushing the Settings button up when the modal mounts
|
||||
- `section.modal` is `position: fixed; z-index: 10000` with `transition: opacity 300ms`. The component flips `active = true` in `ngAfterViewInit` via `setTimeout(0)` so the opacity 0 → 1 transition runs
|
||||
- `ModalStateService.open()` / `.close()` are called in `AfterViewInit`/`OnDestroy`. `page.component` reads `modalState.anyOpen` and binds it to every tower's `[cdkDragDisabled]` so users can't drag towers behind an open modal
|
||||
|
||||
### Carousel card date format
|
||||
|
||||
```ts
|
||||
formatDate(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}); // "May 28, 2026, 14:32"
|
||||
}
|
||||
```
|
||||
|
||||
### Double-slider relative-time labels
|
||||
|
||||
`page.component.ts` formats with `Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })`. Buckets: `<45s` second, `<45min` minute, `<22h` hour, `<26d` day, `<320d` month, else year. Labels are **deduped** by string (multiple distinct timestamps that round to the same "5 hr ago" appear once).
|
||||
|
||||
When `values.length` grows (new block added), the slider snaps the **higher** of `oneValue`/`otherValue` to `MAX - 1` so the newest entry is always visible; the lower thumb (the user's left edge) stays put.
|
||||
|
||||
### Color picker (shared/color-picker)
|
||||
|
||||
- Row of 12 preset color swatches + a rainbow hue slider + a big preview swatch
|
||||
- Saturation and lightness are FIXED at 0.7 / 0.55. Only hue varies
|
||||
- Preset hues `[0, 15, 30, 45, 195, 215, 235, 255, 280, 310, 335, 355]` — skips the green/yellow zone (60°–180°) which muddies with the rose accent palette
|
||||
|
||||
### Tickbox (tasks/tasks.component)
|
||||
|
||||
- `✓` glyph is hidden at rest (`opacity: 0`) and only revealed on interaction: `0.85` (hover/focus-visible), `1` (active). It fades via the `opacity` transition
|
||||
- The tickbox is a `<button>`, so the global animated-underline bar from `forms.scss` (`button:after { content:''; height: 2px; width: 0→100% on hover; background-color: $text-color }`) applies to it. `all: unset` strips the button's own styling but does NOT reach the pseudo-element — so `.tickbox::after` MUST re-assert `width: 100%; height: 100%; background: none`, otherwise on hover a dark `$text-color` bar paints across the top AND the box collapses to 2px (pinned at `top:0`), which centers the glyph near the top
|
||||
- `font: bold 18px/1 $normal-font` is re-asserted on `::after` because `all: unset` drops the font to serif (Times New Roman)
|
||||
- `transform: translateY(1px)` nudges the `✓` to optical centre (it sits a touch high in its em-box); `:active` must re-state the translateY or the glyph jumps when pressed
|
||||
- `.all-task` is `overflow: hidden` (NOT a scroller) and animates to `#all.scrollHeight`; tall lists scroll in the outer `.container` (`overflow-y: auto; max-height: 30vh`). Making `.all-task` itself `overflow-y: auto` pops a scrollbar the moment the tickbox `scale(1.05)`s on hover (transforms widen the scrollable-overflow box)
|
||||
|
||||
### Mobile responsive
|
||||
|
||||
- `$mobile-width: 520px` is the single breakpoint
|
||||
- Tower row: on mobile, `width: calc(66vw - var(--medium-padding)) !important` per tower with `overflow-x: auto` + `scroll-snap-type: x mandatory`. About 1.5 columns visible by default
|
||||
- Carousel cards: `width: 85vw`, placeholders `7.5vw`, carousel `padding: 0 7.5vw` → snap-center lines up perfectly
|
||||
- Modal cards (settings/tower-settings/welcome/confirm-delete): `width: 88vw; padding: var(--medium-padding)` on mobile. Confirm-buttons stack vertically with full width
|
||||
- Block hover effect (`gravitate`) gated behind `@media (hover: hover) and (pointer: fine)` so touch devices don't get stuck scale-up
|
||||
- Viewport meta blocks pinch-zoom: `<meta name="viewport" content="..., maximum-scale=1.0, user-scalable=no" />`
|
||||
- `scrollbar-gutter: stable` + `overflow-y: scroll` on `html` so modal opens don't shift content sideways
|
||||
|
||||
### Tower drag-drop
|
||||
|
||||
- CDK drag-drop on `.towers cdkDropList[orientation=horizontal]`, each tower is `cdkDrag`
|
||||
- Trash zone: `<img class="trash">` is OUTSIDE `.towers` (anchored to `page.component :host` at `bottom: 8px; left: 50%`). The legacy structure — moving it inside `.towers` breaks because it becomes a flex item
|
||||
- Trash-highlight: `pointerenter` on trash → direct DOM `document.querySelector('.cdk-drag-preview').classList.add('trash-highlight')`. Matches legacy approach
|
||||
- Drop over trash → opens a confirm modal (no immediate delete)
|
||||
|
||||
### Welcome modal + example data
|
||||
|
||||
- Shows when `!store.loading() && store.pages().length === 0`. Auto-dismisses when `pages().length > 0`
|
||||
- "Try an example" calls `store.loadExample()` which creates a "Hobbies" page with three towers (Reading, Side projects, Exercise) at varied `created_at` ages so the slider has interesting labels
|
||||
|
||||
## Frontend sharp edges
|
||||
|
||||
- **`crypto.randomUUID()` requires a secure context** (HTTPS or localhost). On a plain-HTTP origin behind nginx, it throws and `init()` rejects, leaving `loading = true` forever. Always fall back to `crypto.getRandomValues` (`uuidV4()` helper in `store.service.ts`)
|
||||
- **Angular 17+ deprecated `fileReplacements`** for env-per-build. Don't use `environment.ts` — use relative API paths everywhere + `proxy.conf.json` for ng serve
|
||||
- **Angular 19+ `application` builder copies `public/`, NOT `src/assets/`**. SVG icons must live at `frontend/public/assets/` to be served at `/assets/foo.svg`. Fonts referenced via `url()` in styles.scss go through the CSS asset pipeline and emit to `/media/`
|
||||
- **Backticks inside `styles:` template literal** close the string early — break TS parsing
|
||||
- **Async iframe-like spawning of agents in this sandbox** (host port forwarding) doesn't work — run e2e via Playwright Docker image on the same `life-towers_default` network
|
||||
|
||||
## Backend sharp edges
|
||||
|
||||
- The 256 KiB payload cap is enforced by middleware reading `Content-Length`. Chunked encoding bypasses the check. Defense-in-depth would also stream `request.stream()`
|
||||
- 10 MiB per-user quota is checked against the request body, NOT the existing user's stored total. With the 256 KiB request cap, the 10 MiB check is effectively unreachable. Documented spec gap
|
||||
- The migrations directory was moved to `src/life_towers/migrations/` (inside the package) to ship as `importlib.resources` data — don't recreate `backend/migrations/`
|
||||
|
||||
## Visual e2e
|
||||
|
||||
- `frontend/e2e/visuals.spec.ts` is the source-of-truth for "what should this look like." It captures ~15 screenshots into `frontend/visuals/` (gitignored)
|
||||
- Add a screenshot every time we land a visual change. Don't merge if it broke the visuals run
|
||||
- Mobile screenshots use a separate test that spawns its own `browser.newContext({ viewport: { width: 390, height: 844 } })` — that's the iPhone 14 Pro viewport
|
||||
|
||||
## Conventions to enforce on changes
|
||||
|
||||
- Never use the legacy `frontend-legacy/` or `backend-legacy/` paths — those folders are GONE. `_legacy_reference/` is the reference, gitignored
|
||||
- Never refactor `library/*.scss` — those files are dropped verbatim from the legacy and downstream components depend on their exports
|
||||
- Never change the API contract in `docs/api-spec.md` without updating both pydantic models, api.py, frontend `models/index.ts`, and the backend tests in `tests/test_api.py`
|
||||
- Never put modal-related elements as direct flex children of a layout container without `:host { display: contents }` on the modal — they'll add invisible flex slots
|
||||
- Always run `npm run build` after a frontend change to catch TS/template errors that don't surface in the IDE
|
||||
- Always run the visuals test after a visual change. Pull the screenshots, look at them, compare to the legacy
|
||||
|
||||
## Where to look next
|
||||
|
||||
- For the API contract: `docs/api-spec.md`
|
||||
- For visual design questions: `docs/DESIGN.md` + `_legacy_reference/frontend/src/library/`
|
||||
- For the sync flow: `frontend/src/app/services/store.service.ts` (the `init()`, `flush()`, `scheduleSave()` chain)
|
||||
- For the falling animation: `frontend/src/app/components/tower/tower.component.ts:reconcile()`
|
||||
- For drag-drop + trash: `frontend/src/app/components/page/page.component.{ts,html,scss}`
|
||||
- For deploy: `Dockerfile`, `docker-compose.yml`, `.forgejo/workflows/`
|
||||
14
README.md
14
README.md
|
|
@ -1,4 +1,6 @@
|
|||
# Life Towers
|
||||
<p align="center">
|
||||
<img src="frontend/public/logo.svg" alt="Life Towers" width="420">
|
||||
</p>
|
||||
|
||||
A personal productivity tool for organising tasks into visual "towers" of blocks, grouped on pages. Each user is identified by a client-generated token — no accounts, no passwords.
|
||||
|
||||
|
|
@ -21,8 +23,9 @@ Then visit http://localhost:8000.
|
|||
For a production-style run, set `LIFE_TOWERS_IMAGE` to point at your registry tag and use the default `docker-compose.yml`:
|
||||
|
||||
```bash
|
||||
LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest \
|
||||
docker compose pull && docker compose up -d
|
||||
export LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
|
@ -33,6 +36,7 @@ LIFE_TOWERS_IMAGE=registry.example.com/life-towers:latest \
|
|||
| `LIFE_TOWERS_PORT` | `8000` | Host port mapped to the container. |
|
||||
| `LIFE_TOWERS_PULL_POLICY` | `missing` | `pull_policy` passed to compose. Set `always` to force-pull on `up`. |
|
||||
| `LIFE_TOWERS_ALLOWED_ORIGIN` | _(empty)_ | If set, restricts CORS to this origin. Leave empty for same-origin mode (the typical setup behind nginx). |
|
||||
| `LIFE_TOWERS_PUBLIC_URL` | _(derived from request)_ | Absolute public URL used for canonical and Open Graph image tags. Set this when serving behind a path prefix or proxy that rewrites the visible URL. |
|
||||
| `LIFE_TOWERS_FORWARDED_ALLOW_IPS` | `*` | (Optional, advanced.) Override uvicorn's `--forwarded-allow-ips` if you want to narrow the set of trusted proxies. |
|
||||
|
||||
## Data persistence
|
||||
|
|
@ -89,6 +93,6 @@ docker compose -f docker-compose.dev.yml down -v
|
|||
|
||||
## Deployment
|
||||
|
||||
Forgejo CI (`.forgejo/workflows/ci.yml`) builds and tests the backend, frontend, and Docker image on every push to `master`. On successful push to `master` it also tags and pushes the image to a registry — configure `REGISTRY_URL`, `REGISTRY_USER`, and `REGISTRY_PASSWORD` as repository secrets.
|
||||
Forgejo CI (`.forgejo/workflows/ci.yml`) tests the backend, frontend, production build, and Playwright e2e flow on every push to `master`.
|
||||
|
||||
The deploy workflow (`.forgejo/workflows/deploy.yml`) triggers on `workflow_dispatch` or a `v*` tag push. It SSHs into the target server and runs `docker compose pull && docker compose up -d`, then polls the healthcheck. The server must have a `.env` file alongside `docker-compose.yml` that pins `LIFE_TOWERS_IMAGE` to the registry tag pushed by CI — otherwise `docker compose pull` is a no-op against the placeholder `life-towers:local`. Configure `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, and `DEPLOY_PATH` as repository secrets before use.
|
||||
The Docker workflow (`.forgejo/workflows/docker.yml`) builds and pushes images tagged `latest` and `sha-<commit>` on pushes to `master` or manual dispatch. It publishes to `vars.REGISTRY` (default `ghcr.io`) under the repository name, using `secrets.GITHUB_TOKEN` for registry auth.
|
||||
|
|
|
|||
795
docs/DESIGN.md
795
docs/DESIGN.md
|
|
@ -1,795 +0,0 @@
|
|||
# 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
127
docs/api-spec.md
|
|
@ -1,127 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,59 +1,11 @@
|
|||
# Frontend
|
||||
# Life Towers Frontend
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.13.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
Angular frontend for the Life Towers app. See the root `README.md` for the full
|
||||
stack setup.
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
npm start # dev server on :4200, with /api proxied to :8000
|
||||
npm test # Vitest unit tests
|
||||
npm run test:e2e # Playwright against PLAYWRIGHT_BASE_URL or localhost:8000
|
||||
npm run build # production bundle
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue