.
289
CLAUDE.md
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
# 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, 002_keep_tasks_open.sql, …
|
||||
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)
|
||||
|
||||
- Always-visible `✓` glyph at `opacity: 0.45` (rest), `0.85` (hover), `1` (active)
|
||||
- `transform: translateY(2px)` is critical — Unicode `✓` has uneven font-metrics, geometric centering looks half a square too high. The 2px nudge moves it to optical center
|
||||
- `:active` state must re-state the translateY or the glyph jumps when pressed
|
||||
|
||||
### 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/`
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,15 @@ import { test } from '@playwright/test';
|
|||
test.describe('Life Towers visuals', () => {
|
||||
test('capture key UI states', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('text=Add a new page to get started!', { timeout: 15000 });
|
||||
await page.screenshot({ path: 'visuals/01-empty-state.png', fullPage: true });
|
||||
await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
|
||||
await page.waitForTimeout(350); // let the welcome modal finish fade-in
|
||||
await page.screenshot({ path: 'visuals/01-welcome-modal.png', fullPage: true });
|
||||
|
||||
// Dismiss the welcome modal with Start fresh, then continue.
|
||||
await page.getByRole('button', { name: 'Start fresh' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
||||
await page.screenshot({ path: 'visuals/01b-empty-state-after-dismiss.png', fullPage: true });
|
||||
|
||||
// Open the page dropdown (without creating a page yet).
|
||||
await page.locator('lt-select-add .top').first().click();
|
||||
|
|
@ -26,7 +33,7 @@ test.describe('Life Towers visuals', () => {
|
|||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(350);
|
||||
await page.locator('input[placeholder="Tower name…"]').fill('Reading');
|
||||
await page.locator('input[placeholder="New tower"]').fill('Reading');
|
||||
await page.screenshot({ path: 'visuals/03-new-tower-modal.png', fullPage: true });
|
||||
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
|
|
@ -100,7 +107,7 @@ test.describe('Life Towers visuals', () => {
|
|||
await page.locator('img[alt="Add tower"]').click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(350);
|
||||
await page.locator('input[placeholder="Tower name…"]').fill('Side projects');
|
||||
await page.locator('input[placeholder="New tower"]').fill('Side projects');
|
||||
await page.locator('lt-tower-settings button[type="submit"]').click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
await page.waitForTimeout(300);
|
||||
|
|
@ -157,4 +164,39 @@ test.describe('Life Towers visuals', () => {
|
|||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: 'visuals/11-settings-modal.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('"Try an example" populates a sample page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
|
||||
await page.waitForTimeout(350);
|
||||
await page.getByRole('button', { name: 'Try an example' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
await page.waitForTimeout(1800);
|
||||
await page.screenshot({ path: 'visuals/12-example-data.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('Mobile viewport — welcome + example + carousel', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
await page.goto((process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:8000') + '/');
|
||||
await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 });
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: 'visuals/13-mobile-welcome.png', fullPage: true });
|
||||
|
||||
await page.getByRole('button', { name: 'Try an example' }).click();
|
||||
await page.waitForSelector('section.modal', { state: 'detached' });
|
||||
await page.waitForTimeout(1800);
|
||||
await page.screenshot({ path: 'visuals/14-mobile-populated.png', fullPage: true });
|
||||
|
||||
// Open the block-edit carousel for the first tower's first task.
|
||||
await page.locator('lt-tasks .container').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
await page.locator('lt-tasks .task-container').first().click();
|
||||
await page.waitForSelector('section.modal.active');
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: 'visuals/15-mobile-carousel.png', fullPage: true });
|
||||
|
||||
await ctx.close();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.7 KiB |
13
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffcf0"/><stop offset="1" stop-color="#ffebeb"/></linearGradient>
|
||||
<linearGradient id="b1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9a6069"/><stop offset="1" stop-color="#7f4f59"/></linearGradient>
|
||||
<linearGradient id="b2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b07480"/><stop offset="1" stop-color="#9a6069"/></linearGradient>
|
||||
<linearGradient id="b3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c98579"/><stop offset="1" stop-color="#b87672"/></linearGradient>
|
||||
<linearGradient id="b4" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e2987f"/><stop offset="1" stop-color="#d4836f"/></linearGradient>
|
||||
<filter id="sh" x="-30%" y="-30%" width="160%" height="190%"><feDropShadow dx="0" dy="6" stdDeviation="7" flood-color="#5d576b" flood-opacity="0.18"/></filter>
|
||||
</defs><rect width="512" height="512" rx="112" fill="url(#bg)"/><g filter="url(#sh)">
|
||||
<rect x="134" y="360" width="244" height="64" rx="18" fill="url(#b1)"/>
|
||||
<rect x="148" y="286" width="216" height="64" rx="18" fill="url(#b2)"/>
|
||||
<rect x="162" y="212" width="188" height="64" rx="18" fill="url(#b3)"/>
|
||||
<g transform="rotate(-7 256 158)"><rect x="176" y="126" width="160" height="64" rx="18" fill="url(#b4)"/></g>
|
||||
</g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.1 KiB |
13
frontend/public/logo-mark.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffcf0"/><stop offset="1" stop-color="#ffebeb"/></linearGradient>
|
||||
<linearGradient id="b1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9a6069"/><stop offset="1" stop-color="#7f4f59"/></linearGradient>
|
||||
<linearGradient id="b2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b07480"/><stop offset="1" stop-color="#9a6069"/></linearGradient>
|
||||
<linearGradient id="b3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c98579"/><stop offset="1" stop-color="#b87672"/></linearGradient>
|
||||
<linearGradient id="b4" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e2987f"/><stop offset="1" stop-color="#d4836f"/></linearGradient>
|
||||
<filter id="sh" x="-30%" y="-30%" width="160%" height="190%"><feDropShadow dx="0" dy="6" stdDeviation="7" flood-color="#5d576b" flood-opacity="0.18"/></filter>
|
||||
</defs><rect width="512" height="512" rx="112" fill="url(#bg)"/><g filter="url(#sh)">
|
||||
<rect x="134" y="360" width="244" height="64" rx="18" fill="url(#b1)"/>
|
||||
<rect x="148" y="286" width="216" height="64" rx="18" fill="url(#b2)"/>
|
||||
<rect x="162" y="212" width="188" height="64" rx="18" fill="url(#b3)"/>
|
||||
<g transform="rotate(-7 256 158)"><rect x="176" y="126" width="160" height="64" rx="18" fill="url(#b4)"/></g>
|
||||
</g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
13
frontend/public/logo-mono.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="1118" height="200" viewBox="0 0 1118 200" xmlns="http://www.w3.org/2000/svg"><defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffcf0"/><stop offset="1" stop-color="#ffebeb"/></linearGradient>
|
||||
<linearGradient id="b1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9a6069"/><stop offset="1" stop-color="#7f4f59"/></linearGradient>
|
||||
<linearGradient id="b2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b07480"/><stop offset="1" stop-color="#9a6069"/></linearGradient>
|
||||
<linearGradient id="b3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c98579"/><stop offset="1" stop-color="#b87672"/></linearGradient>
|
||||
<linearGradient id="b4" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e2987f"/><stop offset="1" stop-color="#d4836f"/></linearGradient>
|
||||
<filter id="sh" x="-30%" y="-30%" width="160%" height="190%"><feDropShadow dx="0" dy="6" stdDeviation="7" flood-color="#5d576b" flood-opacity="0.18"/></filter>
|
||||
</defs><g transform="scale(0.39063)"><rect width="512" height="512" rx="112" fill="url(#bg)"/><g filter="url(#sh)">
|
||||
<rect x="134" y="360" width="244" height="64" rx="18" fill="url(#b1)"/>
|
||||
<rect x="148" y="286" width="216" height="64" rx="18" fill="url(#b2)"/>
|
||||
<rect x="162" y="212" width="188" height="64" rx="18" fill="url(#b3)"/>
|
||||
<g transform="rotate(-7 256 158)"><rect x="176" y="126" width="160" height="64" rx="18" fill="url(#b4)"/></g>
|
||||
</g></g><g transform="translate(229.76 158.40)"><path d="M91.04 0L14.24 0L14.24-113.60L25.44-113.60L25.44-9.92L91.04-9.92L91.04 0M116.16 0L105.28 0L105.28-83.36L116.16-83.36L116.16 0M116.16-100.80L105.28-100.80L105.28-116.80L116.16-116.80L116.16-100.80M155.04 0L144.16 0L144.16-74.72L132.64-74.72L132.64-83.36L144.16-83.36L144.16-85.60Q144.16-95.84 147.12-103.20Q150.08-110.56 155.60-114.48Q161.12-118.40 168.64-118.40Q173.60-118.40 178.24-116.96Q182.88-115.52 186.24-113.12L182.88-105.28Q180.80-107.04 177.44-108Q174.08-108.96 170.72-108.96Q163.20-108.96 159.12-103.04Q155.04-97.12 155.04-85.92L155.04-83.36L178.08-83.36L178.08-74.72L155.04-74.72L155.04 0M228.96 1.60Q220 1.60 212.32-1.84Q204.64-5.28 199.04-11.28Q193.44-17.28 190.32-25.12Q187.20-32.96 187.20-41.92Q187.20-53.60 192.56-63.36Q197.92-73.12 207.36-78.96Q216.80-84.80 228.80-84.80Q241.12-84.80 250.32-78.88Q259.52-72.96 264.80-63.28Q270.08-53.60 270.08-42.08Q270.08-40.80 270.08-39.60Q270.08-38.40 269.92-37.76L198.56-37.76Q199.36-28.80 203.60-21.84Q207.84-14.88 214.64-10.80Q221.44-6.72 229.44-6.72Q237.60-6.72 244.88-10.88Q252.16-15.04 255.04-21.76L264.48-19.20Q261.92-13.28 256.64-8.48Q251.36-3.68 244.24-1.04Q237.12 1.60 228.96 1.60M198.24-45.60L259.84-45.60Q259.20-54.72 254.96-61.60Q250.72-68.48 243.92-72.40Q237.12-76.32 228.96-76.32Q220.80-76.32 214.08-72.40Q207.36-68.48 203.12-61.52Q198.88-54.56 198.24-45.60M410.56-113.60L410.56-103.68L370.08-103.68L370.08 0L358.88 0L358.88-103.68L318.40-103.68L318.40-113.60L410.56-113.60M460.80 1.60Q451.84 1.60 444.24-1.84Q436.64-5.28 431.12-11.28Q425.60-17.28 422.56-25.04Q419.52-32.80 419.52-41.44Q419.52-50.40 422.56-58.16Q425.60-65.92 431.20-71.92Q436.80-77.92 444.40-81.36Q452-84.80 460.96-84.80Q469.92-84.80 477.44-81.36Q484.96-77.92 490.56-71.92Q496.16-65.92 499.20-58.16Q502.24-50.40 502.24-41.44Q502.24-32.80 499.20-25.04Q496.16-17.28 490.64-11.28Q485.12-5.28 477.52-1.84Q469.92 1.60 460.80 1.60M430.56-41.28Q430.56-32 434.64-24.40Q438.72-16.80 445.60-12.40Q452.48-8 460.80-8Q469.12-8 476-12.48Q482.88-16.96 487.04-24.72Q491.20-32.48 491.20-41.60Q491.20-50.88 487.04-58.56Q482.88-66.24 476-70.72Q469.12-75.20 460.80-75.20Q452.48-75.20 445.68-70.56Q438.88-65.92 434.72-58.32Q430.56-50.72 430.56-41.28M595.20-11.20L625.44-83.36L636.16-83.36L600.32 0L590.88 0L573.44-41.44L556.16 0L546.72 0L510.88-83.36L521.44-83.36L551.84-11.20L567.20-48.80L553.12-83.20L562.88-83.20L573.44-56.16L584.16-83.20L593.76-83.20L579.84-48.80L595.20-11.20M686.56 1.60Q677.60 1.60 669.92-1.84Q662.24-5.28 656.64-11.28Q651.04-17.28 647.92-25.12Q644.80-32.96 644.80-41.92Q644.80-53.60 650.16-63.36Q655.52-73.12 664.96-78.96Q674.40-84.80 686.40-84.80Q698.72-84.80 707.92-78.88Q717.12-72.96 722.40-63.28Q727.68-53.60 727.68-42.08Q727.68-40.80 727.68-39.60Q727.68-38.40 727.52-37.76L656.16-37.76Q656.96-28.80 661.20-21.84Q665.44-14.88 672.24-10.80Q679.04-6.72 687.04-6.72Q695.20-6.72 702.48-10.88Q709.76-15.04 712.64-21.76L722.08-19.20Q719.52-13.28 714.24-8.48Q708.96-3.68 701.84-1.04Q694.72 1.60 686.56 1.60M655.84-45.60L717.44-45.60Q716.80-54.72 712.56-61.60Q708.32-68.48 701.52-72.40Q694.72-76.32 686.56-76.32Q678.40-76.32 671.68-72.40Q664.96-68.48 660.72-61.52Q656.48-54.56 655.84-45.60M786.08-83.68L786.08-73.76Q775.20-73.44 766.96-67.68Q758.72-61.92 755.36-51.84L755.36 0L744.48 0L744.48-83.36L754.72-83.36L754.72-63.36Q759.04-72.16 766.16-77.60Q773.28-83.04 781.28-83.68Q782.88-83.84 784.08-83.84Q785.28-83.84 786.08-83.68M828 1.60Q817.76 1.60 808.96-1.76Q800.16-5.12 793.76-12L798.24-19.68Q805.28-13.12 812.40-10.16Q819.52-7.20 827.52-7.20Q837.28-7.20 843.36-11.12Q849.44-15.04 849.44-22.40Q849.44-27.36 846.48-30Q843.52-32.64 838-34.32Q832.48-36 824.80-37.92Q816.16-40.32 810.32-42.96Q804.48-45.60 801.52-49.68Q798.56-53.76 798.56-60.32Q798.56-68.48 802.64-73.84Q806.72-79.20 813.84-82Q820.96-84.80 829.76-84.80Q839.36-84.80 846.72-81.76Q854.08-78.72 858.72-73.28L853.44-65.92Q848.96-71.04 842.80-73.52Q836.64-76 829.12-76Q824-76 819.36-74.64Q814.72-73.28 811.76-70.16Q808.80-67.04 808.80-61.60Q808.80-57.12 811.04-54.64Q813.28-52.16 817.76-50.48Q822.24-48.80 828.80-46.88Q838.24-44.32 845.28-41.68Q852.32-39.04 856.16-34.88Q860-30.72 860-23.20Q860-11.52 851.20-4.96Q842.40 1.60 828 1.60" fill="#5d576b"/></g></svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
13
frontend/public/logo.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="1118" height="200" viewBox="0 0 1118 200" xmlns="http://www.w3.org/2000/svg"><defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fffcf0"/><stop offset="1" stop-color="#ffebeb"/></linearGradient>
|
||||
<linearGradient id="b1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9a6069"/><stop offset="1" stop-color="#7f4f59"/></linearGradient>
|
||||
<linearGradient id="b2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#b07480"/><stop offset="1" stop-color="#9a6069"/></linearGradient>
|
||||
<linearGradient id="b3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c98579"/><stop offset="1" stop-color="#b87672"/></linearGradient>
|
||||
<linearGradient id="b4" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e2987f"/><stop offset="1" stop-color="#d4836f"/></linearGradient>
|
||||
<filter id="sh" x="-30%" y="-30%" width="160%" height="190%"><feDropShadow dx="0" dy="6" stdDeviation="7" flood-color="#5d576b" flood-opacity="0.18"/></filter>
|
||||
</defs><g transform="scale(0.39063)"><rect width="512" height="512" rx="112" fill="url(#bg)"/><g filter="url(#sh)">
|
||||
<rect x="134" y="360" width="244" height="64" rx="18" fill="url(#b1)"/>
|
||||
<rect x="148" y="286" width="216" height="64" rx="18" fill="url(#b2)"/>
|
||||
<rect x="162" y="212" width="188" height="64" rx="18" fill="url(#b3)"/>
|
||||
<g transform="rotate(-7 256 158)"><rect x="176" y="126" width="160" height="64" rx="18" fill="url(#b4)"/></g>
|
||||
</g></g><g transform="translate(229.76 158.40)"><path d="M91.04 0L14.24 0L14.24-113.60L25.44-113.60L25.44-9.92L91.04-9.92L91.04 0M116.16 0L105.28 0L105.28-83.36L116.16-83.36L116.16 0M116.16-100.80L105.28-100.80L105.28-116.80L116.16-116.80L116.16-100.80M155.04 0L144.16 0L144.16-74.72L132.64-74.72L132.64-83.36L144.16-83.36L144.16-85.60Q144.16-95.84 147.12-103.20Q150.08-110.56 155.60-114.48Q161.12-118.40 168.64-118.40Q173.60-118.40 178.24-116.96Q182.88-115.52 186.24-113.12L182.88-105.28Q180.80-107.04 177.44-108Q174.08-108.96 170.72-108.96Q163.20-108.96 159.12-103.04Q155.04-97.12 155.04-85.92L155.04-83.36L178.08-83.36L178.08-74.72L155.04-74.72L155.04 0M228.96 1.60Q220 1.60 212.32-1.84Q204.64-5.28 199.04-11.28Q193.44-17.28 190.32-25.12Q187.20-32.96 187.20-41.92Q187.20-53.60 192.56-63.36Q197.92-73.12 207.36-78.96Q216.80-84.80 228.80-84.80Q241.12-84.80 250.32-78.88Q259.52-72.96 264.80-63.28Q270.08-53.60 270.08-42.08Q270.08-40.80 270.08-39.60Q270.08-38.40 269.92-37.76L198.56-37.76Q199.36-28.80 203.60-21.84Q207.84-14.88 214.64-10.80Q221.44-6.72 229.44-6.72Q237.60-6.72 244.88-10.88Q252.16-15.04 255.04-21.76L264.48-19.20Q261.92-13.28 256.64-8.48Q251.36-3.68 244.24-1.04Q237.12 1.60 228.96 1.60M198.24-45.60L259.84-45.60Q259.20-54.72 254.96-61.60Q250.72-68.48 243.92-72.40Q237.12-76.32 228.96-76.32Q220.80-76.32 214.08-72.40Q207.36-68.48 203.12-61.52Q198.88-54.56 198.24-45.60" fill="#5d576b"/><path d="M410.56-113.60L410.56-103.68L370.08-103.68L370.08 0L358.88 0L358.88-103.68L318.40-103.68L318.40-113.60L410.56-113.60M460.80 1.60Q451.84 1.60 444.24-1.84Q436.64-5.28 431.12-11.28Q425.60-17.28 422.56-25.04Q419.52-32.80 419.52-41.44Q419.52-50.40 422.56-58.16Q425.60-65.92 431.20-71.92Q436.80-77.92 444.40-81.36Q452-84.80 460.96-84.80Q469.92-84.80 477.44-81.36Q484.96-77.92 490.56-71.92Q496.16-65.92 499.20-58.16Q502.24-50.40 502.24-41.44Q502.24-32.80 499.20-25.04Q496.16-17.28 490.64-11.28Q485.12-5.28 477.52-1.84Q469.92 1.60 460.80 1.60M430.56-41.28Q430.56-32 434.64-24.40Q438.72-16.80 445.60-12.40Q452.48-8 460.80-8Q469.12-8 476-12.48Q482.88-16.96 487.04-24.72Q491.20-32.48 491.20-41.60Q491.20-50.88 487.04-58.56Q482.88-66.24 476-70.72Q469.12-75.20 460.80-75.20Q452.48-75.20 445.68-70.56Q438.88-65.92 434.72-58.32Q430.56-50.72 430.56-41.28M595.20-11.20L625.44-83.36L636.16-83.36L600.32 0L590.88 0L573.44-41.44L556.16 0L546.72 0L510.88-83.36L521.44-83.36L551.84-11.20L567.20-48.80L553.12-83.20L562.88-83.20L573.44-56.16L584.16-83.20L593.76-83.20L579.84-48.80L595.20-11.20M686.56 1.60Q677.60 1.60 669.92-1.84Q662.24-5.28 656.64-11.28Q651.04-17.28 647.92-25.12Q644.80-32.96 644.80-41.92Q644.80-53.60 650.16-63.36Q655.52-73.12 664.96-78.96Q674.40-84.80 686.40-84.80Q698.72-84.80 707.92-78.88Q717.12-72.96 722.40-63.28Q727.68-53.60 727.68-42.08Q727.68-40.80 727.68-39.60Q727.68-38.40 727.52-37.76L656.16-37.76Q656.96-28.80 661.20-21.84Q665.44-14.88 672.24-10.80Q679.04-6.72 687.04-6.72Q695.20-6.72 702.48-10.88Q709.76-15.04 712.64-21.76L722.08-19.20Q719.52-13.28 714.24-8.48Q708.96-3.68 701.84-1.04Q694.72 1.60 686.56 1.60M655.84-45.60L717.44-45.60Q716.80-54.72 712.56-61.60Q708.32-68.48 701.52-72.40Q694.72-76.32 686.56-76.32Q678.40-76.32 671.68-72.40Q664.96-68.48 660.72-61.52Q656.48-54.56 655.84-45.60M786.08-83.68L786.08-73.76Q775.20-73.44 766.96-67.68Q758.72-61.92 755.36-51.84L755.36 0L744.48 0L744.48-83.36L754.72-83.36L754.72-63.36Q759.04-72.16 766.16-77.60Q773.28-83.04 781.28-83.68Q782.88-83.84 784.08-83.84Q785.28-83.84 786.08-83.68M828 1.60Q817.76 1.60 808.96-1.76Q800.16-5.12 793.76-12L798.24-19.68Q805.28-13.12 812.40-10.16Q819.52-7.20 827.52-7.20Q837.28-7.20 843.36-11.12Q849.44-15.04 849.44-22.40Q849.44-27.36 846.48-30Q843.52-32.64 838-34.32Q832.48-36 824.80-37.92Q816.16-40.32 810.32-42.96Q804.48-45.60 801.52-49.68Q798.56-53.76 798.56-60.32Q798.56-68.48 802.64-73.84Q806.72-79.20 813.84-82Q820.96-84.80 829.76-84.80Q839.36-84.80 846.72-81.76Q854.08-78.72 858.72-73.28L853.44-65.92Q848.96-71.04 842.80-73.52Q836.64-76 829.12-76Q824-76 819.36-74.64Q814.72-73.28 811.76-70.16Q808.80-67.04 808.80-61.60Q808.80-57.12 811.04-54.64Q813.28-52.16 817.76-50.48Q822.24-48.80 828.80-46.88Q838.24-44.32 845.28-41.68Q852.32-39.04 856.16-34.88Q860-30.72 860-23.20Q860-11.52 851.20-4.96Q842.40 1.60 828 1.60" fill="#a2666f"/></g></svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
BIN
frontend/public/og-image.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
|
|
@ -27,9 +27,13 @@ import { getColorOfTag } from '../../utils/color';
|
|||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
@include gravitate();
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BlockComponent {
|
||||
|
|
|
|||
|
|
@ -177,6 +177,11 @@ interface EditedValue {
|
|||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
scroll-snap-type: x mandatory;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
padding: 0 7.5vw;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
|
|
@ -192,8 +197,12 @@ interface EditedValue {
|
|||
flex: 0 0 auto;
|
||||
width: 66vw;
|
||||
max-width: 400px;
|
||||
scroll-snap-align: center;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 300px;
|
||||
width: 85vw;
|
||||
max-width: 85vw;
|
||||
padding: var(--medium-padding);
|
||||
margin: calc(var(--medium-padding) / 2);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
box-sizing: border-box;
|
||||
|
|
@ -246,6 +255,10 @@ interface EditedValue {
|
|||
opacity: 0 !important;
|
||||
width: 60vw;
|
||||
max-width: 60vw;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 7.5vw;
|
||||
max-width: 7.5vw;
|
||||
}
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
|
@ -256,7 +269,7 @@ interface EditedValue {
|
|||
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
@include exit();
|
||||
}
|
||||
|
||||
|
|
@ -291,6 +304,13 @@ interface EditedValue {
|
|||
transform: translateY(-50%) translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
lt-select-add, lt-toggle {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
|
@ -389,10 +409,13 @@ export class BlockEditComponent implements AfterViewInit {
|
|||
|
||||
formatDate(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
// e.g. "May 28, 2026, 14:32"
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ import { ModalStateService } from '../../services/modal-state.service';
|
|||
styles: `
|
||||
@import '../../../library/main';
|
||||
|
||||
/* lt-modal host must not occupy a flex slot in its parent — section.modal
|
||||
is position:fixed, but the host element itself would otherwise take a
|
||||
slot and push siblings around when it mounts/unmounts.
|
||||
display: contents removes the host box without affecting descendants. */
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
section.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
|
|
|||
|
|
@ -111,7 +111,11 @@ const UUIDV4_RE =
|
|||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 480px;
|
||||
@media (max-width: $mobile-width) { width: 300px; }
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
|
|
@ -171,6 +175,13 @@ const UUIDV4_RE =
|
|||
button {
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
flex-wrap: wrap;
|
||||
input { width: 100%; }
|
||||
button { margin-left: auto; }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,10 +21,7 @@ export interface TowerSettingsResult {
|
|||
imports: [ReactiveFormsModule, ColorPickerComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="header">
|
||||
<div class="exit" (click)="close.emit()" role="button" aria-label="Close"></div>
|
||||
<h2>{{ tower() ? 'Tower settings' : 'New tower' }}</h2>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<input
|
||||
|
|
@ -32,12 +29,15 @@ export interface TowerSettingsResult {
|
|||
name="towerName"
|
||||
type="text"
|
||||
formControlName="name"
|
||||
placeholder="Tower name…"
|
||||
[placeholder]="tower() ? 'Tower name…' : 'New tower'"
|
||||
maxlength="200"
|
||||
autocomplete="off"
|
||||
class="title-input"
|
||||
/>
|
||||
|
||||
<div class="picker-row">
|
||||
<lt-color-picker [color]="currentColor" (colorChange)="onColorChange($event)" />
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="form.invalid">
|
||||
{{ tower() ? 'Save' : 'Create tower' }}
|
||||
|
|
@ -55,26 +55,41 @@ export interface TowerSettingsResult {
|
|||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 400px;
|
||||
@media (max-width: $mobile-width) { width: 300px; }
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
padding-top: calc(var(--large-padding) + var(--medium-padding));
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
@include inner-spacing(var(--large-padding));
|
||||
display: block;
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: var(--large-padding);
|
||||
top: var(--medium-padding);
|
||||
right: var(--medium-padding);
|
||||
@include exit();
|
||||
}
|
||||
|
||||
form {
|
||||
@include inner-spacing(var(--large-padding));
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
.title-input {
|
||||
@include title-text();
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
// Generous gap between the name input and the color picker — the picker
|
||||
// is a substantial control and crowding it against the title looks busy.
|
||||
.picker-row {
|
||||
padding-top: var(--medium-padding);
|
||||
}
|
||||
|
||||
button {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@
|
|||
&:not(:nth-last-child(1)) {
|
||||
margin-right: var(--medium-padding);
|
||||
@media (max-width: $mobile-width) {
|
||||
margin-right: var(--small-padding);
|
||||
// Was --small-padding (7.5px) — too tight to read as a real gap
|
||||
// between white tower cards on the cream-pink background.
|
||||
margin-right: var(--medium-padding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +78,35 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile: fixed-width towers with horizontal scroll (1.5-column rhythm).
|
||||
// This block is declared AFTER the @for loop so it wins the specificity race
|
||||
// without needing !important on every property — only the width triples need it
|
||||
// to beat the per-child-count selectors generated above.
|
||||
@media (max-width: $mobile-width) {
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x mandatory;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
// Side padding lets the first and last tower scroll fully into view.
|
||||
padding: 0 var(--medium-padding);
|
||||
max-width: 100%;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Override the @for width-calc rules above.
|
||||
& > * {
|
||||
width: calc(66vw - var(--small-padding)) !important;
|
||||
max-width: calc(66vw - var(--small-padding)) !important;
|
||||
min-width: calc(66vw - var(--small-padding)) !important;
|
||||
scroll-snap-align: start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.double-slider-container {
|
||||
|
|
@ -89,7 +120,11 @@
|
|||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 500px;
|
||||
@media (max-width: $mobile-width) { width: 300px; }
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
|
|
@ -101,7 +136,7 @@
|
|||
@include center-child();
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: var(--large-padding);
|
||||
right: var(--large-padding);
|
||||
@include exit();
|
||||
}
|
||||
}
|
||||
|
|
@ -115,11 +150,21 @@
|
|||
justify-content: center;
|
||||
gap: var(--large-padding);
|
||||
|
||||
button {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: #b53f3f;
|
||||
border-bottom-color: #b53f3f55;
|
||||
&:after { background-color: #b53f3f; }
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
flex-direction: column;
|
||||
gap: var(--small-padding);
|
||||
button { width: 100%; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,14 +11,23 @@
|
|||
|
||||
.select-add-container {
|
||||
width: 250px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 80vw;
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-container {
|
||||
flex: 1 0 auto;
|
||||
// Generous breathing room between the page selector dropdown and the
|
||||
// towers — the dropdown can open downward without crowding the towers.
|
||||
padding-top: var(--large-padding);
|
||||
@media (max-width: $mobile-width) {
|
||||
padding-top: var(--medium-padding);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
|
|
@ -27,5 +36,10 @@
|
|||
&.transparent {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
margin-top: var(--medium-padding);
|
||||
font-size: var(--medium-font-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ export interface DoubleSliderRange<T> {
|
|||
position: relative;
|
||||
margin: calc(#{$slider-size} / 2) auto 0 auto;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
max-width: 90vw;
|
||||
margin-top: calc(#{$slider-size} / 2);
|
||||
}
|
||||
|
||||
label { display: none; }
|
||||
|
||||
input[type='range'] {
|
||||
|
|
@ -145,6 +150,12 @@ export interface DoubleSliderRange<T> {
|
|||
transition: transform $long-animation-time;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
font-size: var(--small-font-size);
|
||||
margin-top: $slider-size;
|
||||
span { margin-top: 10px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,10 @@ import { getColorOfTag } from '../../utils/color';
|
|||
align-items: center;
|
||||
gap: var(--small-padding);
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
gap: calc(var(--small-padding) / 2);
|
||||
}
|
||||
|
||||
&:hover p {
|
||||
@media (min-width: $mobile-width) {
|
||||
color: inherit !important;
|
||||
|
|
|
|||
|
|
@ -166,6 +166,11 @@ interface StyledBlock extends Block {
|
|||
|
||||
@include inner-spacing(var(--medium-padding));
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
@include inner-spacing(var(--small-padding));
|
||||
padding: var(--small-padding);
|
||||
}
|
||||
|
||||
width: 100%;
|
||||
|
||||
:before {
|
||||
|
|
@ -191,6 +196,11 @@ interface StyledBlock extends Block {
|
|||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
min-height: 44px;
|
||||
max-height: 25vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
|
|
@ -261,6 +271,10 @@ interface StyledBlock extends Block {
|
|||
@media (min-width: $mobile-width) {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,11 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
|||
@include card();
|
||||
width: 66vw;
|
||||
max-width: 480px;
|
||||
@media (max-width: $mobile-width) { width: 300px; }
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 88vw;
|
||||
max-width: 88vw;
|
||||
padding: var(--medium-padding);
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
position: relative;
|
||||
|
|
@ -72,6 +76,10 @@ import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
|||
gap: var(--large-padding);
|
||||
margin-top: var(--large-padding);
|
||||
|
||||
button {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
color: $accent-color;
|
||||
border-bottom-color: rgba($accent-color, 0.33);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,18 @@
|
|||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#5d576b" />
|
||||
<meta name="description" content="Organise your tasks into visual towers of blocks, grouped on pages." />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
|
||||
<meta property="og:title" content="Life Towers" />
|
||||
<meta property="og:description" content="Organise your tasks into visual towers of blocks." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="og-image.png" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
|
|
|||