diff --git a/Dockerfile b/Dockerfile index 483f8e6..6327bb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,24 +7,11 @@ WORKDIR /build # this. The trailing slash matters — it becomes and the service # worker (ngsw.json) URL prefix. ARG BASE_HREF=/ -# Toggle the built-in PWA service worker. Defaults to "enabled" so prod images -# ship the full offline-capable PWA unchanged. The dev / e2e compose overrides -# this to "disabled" (see docker-compose.dev.yml): it swaps Angular's real -# ngsw-worker.js for a self-unregistering safety worker and drops ngsw.json, so -# a `--build` rebuild is never shadowed by a stale, client-cached app shell on -# the fixed localhost origin. (Docker layer caching already busts the SPA build -# on FE source changes; this handles the browser-side service-worker cache.) -ARG SERVICE_WORKER=enabled COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci COPY frontend/ ./ +RUN npm run build -- --base-href="$BASE_HREF" # Angular's application builder outputs to dist/frontend/browser/ -RUN npm run build -- --base-href="$BASE_HREF" \ - && if [ "$SERVICE_WORKER" = "disabled" ]; then \ - echo "Neutralising service worker for dev/e2e image"; \ - rm -f dist/frontend/browser/ngsw.json; \ - cp safety-worker.js dist/frontend/browser/ngsw-worker.js; \ - fi # Stage 2: runtime FROM python:3.13-slim diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c6db81e..f9f3fd3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,13 +9,7 @@ services: life-towers: - build: - context: . - args: - # Strip the PWA service worker from this image so `up --build` always - # serves the freshly-built FE assets instead of a stale, SW-cached app - # shell. See the SERVICE_WORKER arg in the Dockerfile. - SERVICE_WORKER: disabled + build: . image: life-towers:dev ports: - "8000:8000" diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 2043c51..2493014 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -180,41 +180,4 @@ test.describe('Life Towers smoke test', () => { }); await expectTaskListOpen(page, 'Clean up alerts'); }); - - // Regression: opening the block-edit carousel must position the target card - // INSTANTLY, not animate a scroll across the whole strip. The carousel sets - // `scroll-behavior: smooth`, so scrollTo({behavior:'auto'}) would animate — - // very visible on mobile where the strip can be thousands of px wide. - test('block-edit carousel opens centered without animating the scroll', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }); - await page.goto('/'); - - await expect(page.getByText('Welcome to Life Towers')).toBeVisible({ timeout: 15000 }); - await page.getByRole('button', { name: 'Load sample towers' }).click(); - await page.waitForSelector('section.modal', { state: 'detached' }); - // Let the falling animation settle. - await page.waitForTimeout(1800); - - // Open the carousel on a done block deep in the strip (the last square). - const squares = page.locator('lt-block'); - const squareCount = await squares.count(); - expect(squareCount).toBeGreaterThan(0); // sample data must have produced done blocks - await squares.nth(squareCount - 1).click(); - await page.waitForSelector('lt-block-edit .carousel'); - - // Sample scrollLeft immediately and a frame later: an animated scroll would - // still be moving; an instant jump is already at its final value. - const carousel = page.locator('lt-block-edit .carousel'); - const first = await carousel.evaluate((c: HTMLElement) => c.scrollLeft); - await page.waitForTimeout(60); - const second = await carousel.evaluate((c: HTMLElement) => c.scrollLeft); - expect(Math.abs(second - first)).toBeLessThan(2); // not mid-animation - - // The active card is centered: left/right viewport gaps match within a few px. - const gaps = await page.locator('lt-block-edit .card.active').evaluate((el: HTMLElement) => { - const r = el.getBoundingClientRect(); - return { left: r.left, right: window.innerWidth - r.right }; - }); - expect(Math.abs(gaps.left - gaps.right)).toBeLessThan(8); - }); }); diff --git a/frontend/safety-worker.js b/frontend/safety-worker.js deleted file mode 100644 index e5f867a..0000000 --- a/frontend/safety-worker.js +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Safety service worker — shipped ONLY in the dev / e2e Docker image - * (Dockerfile build arg SERVICE_WORKER=disabled) in place of Angular's real - * ngsw-worker.js. - * - * Why it exists: the dev compose builds a production SPA bundle, so the PWA - * service worker is enabled (`enabled: !isDevMode()` in app.config.ts). On the - * fixed localhost:8000 origin it would keep serving a stale, client-cached app - * shell across `docker compose -f docker-compose.dev.yml up --build` rebuilds, - * shadowing freshly-built FE assets. - * - * This worker's only job is to disappear: it claims any open clients, deletes - * every Cache Storage entry, then unregisters itself. Any previously-installed - * real service worker is evicted on its next update check (the browser fetches - * this changed ngsw-worker.js and replaces the old one with this), and no - * service worker is ever left controlling the page — so assets are always - * fetched from network, where index.html is `no-cache` and bundles are - * content-hashed. - * - * It deliberately does NOT call client.navigate(): the bundle re-registers on - * every load, so a forced reload would create a loop. The only cost is a - * harmless register -> unregister cycle per load. - */ -self.addEventListener('install', () => self.skipWaiting()); - -self.addEventListener('activate', (event) => { - event.waitUntil( - (async () => { - try { - await self.clients.claim(); - const keys = await caches.keys(); - await Promise.all(keys.map((key) => caches.delete(key))); - } finally { - await self.registration.unregister(); - } - })(), - ); -}); diff --git a/frontend/src/app/components/modal/block-edit.component.ts b/frontend/src/app/components/modal/block-edit.component.ts index 60223fc..58cad90 100644 --- a/frontend/src/app/components/modal/block-edit.component.ts +++ b/frontend/src/app/components/modal/block-edit.component.ts @@ -68,8 +68,8 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit tabindex="0" [attr.aria-label]="'Focus ' + (editedFor(b.id).tag || 'block')" (click)="onCardClick(i + 1)" - (keydown.enter)="onCardKey($event, i + 1)" - (keydown.space)="onCardKey($event, i + 1)" + (keydown.enter)="onCardClick(i + 1)" + (keydown.space)="$event.preventDefault(); onCardClick(i + 1)" >
@@ -151,8 +151,8 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit tabindex="0" aria-label="Focus create card" (click)="onCardClick(blocks().length + 1)" - (keydown.enter)="onCardKey($event, blocks().length + 1)" - (keydown.space)="onCardKey($event, blocks().length + 1)" + (keydown.enter)="onCardClick(blocks().length + 1)" + (keydown.space)="$event.preventDefault(); onCardClick(blocks().length + 1)" >
@@ -242,12 +242,6 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit left: 0; right: 0; bottom: 0; - // Cover the *visible* viewport. Sizing via top:0/bottom:0 uses the layout - // viewport, which on mobile extends behind the browser's bottom toolbar - // (and the soft keyboard) — so the vertically-centered card gets its - // bottom cut off. 100dvh tracks the area that's actually on screen; - // browsers without dvh fall back to the top/bottom inset above. - height: 100dvh; z-index: 10001; // above modal backdrop (10000) @media (max-height: $min-height) { @@ -285,12 +279,6 @@ export function createDoneValue(defaultDone: boolean, currentDone: boolean, edit @media (max-width: $mobile-width) { padding: var(--title-clearance) var(--medium-padding) var(--medium-padding); - // Keep the card centered when it fits, but never clip it: 'safe center' - // falls back to top-alignment when the card is taller than the visible - // viewport, and overflow-y lets the user scroll down to the bottom - // (delete/create button) — e.g. when the soft keyboard shrinks the view. - align-items: safe center; - overflow-y: auto; } @media (max-height: $min-height) { @@ -800,19 +788,6 @@ export class BlockEditComponent implements AfterViewInit { } } - /** - * Activate a card via Space/Enter only when the card itself is focused. The - * card is a role="button" that wraps the description textarea and the tag - * input; without this guard the keydown bubbles up from those fields and the - * space handler's preventDefault() swallows the space, making it impossible - * to type spaces while editing a block. - */ - onCardKey(event: Event, idx: number): void { - if (event.target !== event.currentTarget) return; - event.preventDefault(); - this.onCardClick(idx); - } - /** Close the carousel when the user clicks anywhere that isn't a real card. */ onBackdropClick(event: MouseEvent): void { const target = event.target as HTMLElement | null; @@ -840,12 +815,7 @@ export class BlockEditComponent implements AfterViewInit { if (!card) return; const left = card.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; - // 'instant' (not 'auto') is required: the carousel sets `scroll-behavior: - // smooth`, and 'auto' defers to that — so the initial open would *animate* - // a scroll across the whole strip to reach the target card (very visible on - // mobile, where the carousel can be thousands of px wide). Tap-to-navigate - // still passes smooth=true for the nice slide between cards. - container.scrollTo({ left, behavior: smooth ? 'smooth' : 'instant' }); + container.scrollTo({ left, behavior: smooth ? 'smooth' : 'auto' }); this.activeIdx.set(idx); } diff --git a/frontend/src/app/components/modal/modal.component.ts b/frontend/src/app/components/modal/modal.component.ts index 097e12e..47c9eb8 100644 --- a/frontend/src/app/components/modal/modal.component.ts +++ b/frontend/src/app/components/modal/modal.component.ts @@ -97,21 +97,11 @@ export class ModalComponent implements AfterViewInit, OnDestroy { private readonly dialogRef = viewChild>('dialog'); private previousFocus: HTMLElement | null = null; private readonly modalState = inject(ModalStateService); - private readonly host = inject>(ElementRef); ngAfterViewInit(): void { this.previousFocus = document.activeElement as HTMLElement; // Track open state so towers can be locked while any modal is mounted. this.modalState.open(); - // Hoist the modal to so its position:fixed references the viewport. - // Tower-level modals (block-edit, tower-settings) are rendered inside the - // .towers horizontal scroll container; on iOS Safari a position:fixed - // descendant of a scrolling/overflow ancestor is clipped to that ancestor's - // box instead of the viewport — cutting off the card's bottom and confining - // the backdrop to the towers band (title + sliders show through). Moving the - // host out of that ancestor restores true viewport-fixed behaviour. Angular - // removes the node via its *current* parent on destroy, so this is safe. - document.body.appendChild(this.host.nativeElement); // Defer one tick so the opacity transition runs (0 → 1). setTimeout(() => this.active.set(true), 0); } diff --git a/frontend/src/app/components/page/page.component.html b/frontend/src/app/components/page/page.component.html index 0b87386..431d960 100644 --- a/frontend/src/app/components/page/page.component.html +++ b/frontend/src/app/components/page/page.component.html @@ -7,7 +7,6 @@ @for (tower of page().towers; track tower.id) { >(ElementRef); - private readonly injector = inject(Injector); /** True while any lt-modal is mounted — used to lock tower drag. */ readonly modalOpen = this.modalState.anyOpen; readonly mobileDragDisabled = signal(this.isMobileViewport()); @@ -151,32 +146,7 @@ export class PageComponent { onAddTower(result: TowerSettingsResult): void { this.showAddTower.set(false); - const towerId = this.store.addTower(this.page().id, result.name, result.base_color); - this.centerTowerOnMobile(towerId); - } - - /** - * On mobile the tower row is a horizontal scroll-snap container. Adding a - * tower appends it next to the (far-right) "+" button, so without this the - * view stays scrolled on the "+" button rather than the new tower. Center - * the freshly-created tower once it's painted. No-op on desktop, where the - * row is centered and doesn't scroll. - */ - private centerTowerOnMobile(towerId: string): void { - if (!this.isMobileViewport()) return; - afterNextRender( - () => { - const container = this.host.nativeElement.querySelector('.towers'); - const tower = container?.querySelector(`[data-tower-id="${towerId}"]`); - if (!container || !tower) return; - const containerRect = container.getBoundingClientRect(); - const towerRect = tower.getBoundingClientRect(); - const delta = - towerRect.left + towerRect.width / 2 - (containerRect.left + containerRect.width / 2); - container.scrollTo({ left: container.scrollLeft + delta, behavior: 'smooth' }); - }, - { injector: this.injector }, - ); + this.store.addTower(this.page().id, result.name, result.base_color); } onUpdateTower(towerId: string, result: TowerSettingsResult): void { diff --git a/frontend/src/app/components/pages/pages.component.scss b/frontend/src/app/components/pages/pages.component.scss index 9d5f9c6..cdb7480 100644 --- a/frontend/src/app/components/pages/pages.component.scss +++ b/frontend/src/app/components/pages/pages.component.scss @@ -15,7 +15,6 @@ margin: 0 auto; position: relative; z-index: 1000; - @media (max-width: $mobile-width) { width: 80vw; max-width: 320px; @@ -28,9 +27,8 @@ // 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(--small-padding); + padding-top: var(--medium-padding); } } @@ -42,6 +40,7 @@ } @media (max-width: $mobile-width) { + margin-top: var(--medium-padding); font-size: var(--medium-font-size); } } @@ -50,13 +49,11 @@ @include card(); width: 66vw; max-width: 500px; - @media (max-width: $mobile-width) { width: 88vw; max-width: 88vw; padding: var(--medium-padding); } - box-sizing: border-box; padding: var(--large-padding); position: relative; diff --git a/frontend/src/app/components/shared/double-slider/double-slider.component.ts b/frontend/src/app/components/shared/double-slider/double-slider.component.ts index 0ee4469..f4db1e4 100644 --- a/frontend/src/app/components/shared/double-slider/double-slider.component.ts +++ b/frontend/src/app/components/shared/double-slider/double-slider.component.ts @@ -68,7 +68,6 @@ export interface DoubleSliderRange { @media (max-width: $mobile-width) { max-width: 90vw; margin-top: calc(#{$slider-size} / 2); - height: 54px; } label { display: none; } @@ -154,8 +153,8 @@ export interface DoubleSliderRange { @media (max-width: $mobile-width) { font-size: var(--small-font-size); - margin-top: calc(#{$slider-size} - 12px); - span { margin-top: 8px; } + margin-top: $slider-size; + span { margin-top: 10px; } } } } diff --git a/frontend/src/app/services/store.service.ts b/frontend/src/app/services/store.service.ts index 2431c91..71589bc 100644 --- a/frontend/src/app/services/store.service.ts +++ b/frontend/src/app/services/store.service.ts @@ -311,7 +311,7 @@ export class StoreService implements OnDestroy { if (changed) this.scheduleSave(); } - addTower(pageId: string, name: string, base_color: HslColor): string { + addTower(pageId: string, name: string, base_color: HslColor): void { const tower: Tower = { id: uuidV4(), name, base_color, blocks: [] }; this._pages.update((pages) => pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)), @@ -319,7 +319,6 @@ export class StoreService implements OnDestroy { this.analytics.trackStart(); this.analytics.trackTowerCreated(); this.scheduleSave(); - return tower.id; } updateTower(pageId: string, towerId: string, patch: Partial>): void { diff --git a/frontend/src/index.html b/frontend/src/index.html index 7ce5d22..147335a 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -4,10 +4,7 @@ Life Towers - +