From d8b27a15e823a7a523dced9fbd17ce6c51108106 Mon Sep 17 00:00:00 2001 From: Naser Mansour Date: Sat, 31 Oct 2026 18:44:10 +0200 Subject: [PATCH] =?UTF-8?q?24-3:=20signature=20balance=20component=20?= =?UTF-8?q?=E2=80=94=20implemented,=20QA=20Review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/tasks/24-3.md | 2 +- docs/tasks/manual-tests/24-3.md | 15 ++ docs/tasks/statuses.md | 2 +- resources/css/public.css | 2 + resources/js/public.js | 3 +- resources/js/site/balance.js | 153 ++++++++++++++++++ .../views/components/site/balance.blade.php | 10 ++ tests/Feature/Design/SiteBalanceTest.php | 40 +++++ 8 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 docs/tasks/manual-tests/24-3.md create mode 100644 resources/js/site/balance.js create mode 100644 resources/views/components/site/balance.blade.php create mode 100644 tests/Feature/Design/SiteBalanceTest.php diff --git a/docs/tasks/24-3.md b/docs/tasks/24-3.md index faf1163..0fe0ddb 100644 --- a/docs/tasks/24-3.md +++ b/docs/tasks/24-3.md @@ -1,7 +1,7 @@ --- id: 24-3 title: 24-3 Signature Balance Component -status: Ready for Dev +status: QA Review date: 2026-10-31 parent: 24 --- diff --git a/docs/tasks/manual-tests/24-3.md b/docs/tasks/manual-tests/24-3.md new file mode 100644 index 0000000..382397e --- /dev/null +++ b/docs/tasks/manual-tests/24-3.md @@ -0,0 +1,15 @@ +# Manual tests — 24-3 Signature Balance Component + +Automated (passed 2026-10-31, `tests/Feature/Design/SiteBalanceTest.php`, 5 tests): component renders `` +with viewBox, role/aria-label or aria-hidden, size and passthrough attributes; `balance.js` exports init/destroy, makes ids +unique per instance (`++uid`), uses the spec's spring constants and reduced-motion branch; wired into `public.js`; CSS scoped. +The module imports cleanly in Node 22 and the Vite build succeeds. + +Not yet checkable in a browser: no page places the balance until 24-4 (hero) and 24-5 (finale). Run these once 24-4/24-5 are in: + +- [ ] Home hero: balance appears tilted and settles level within ~4 seconds without touching the mouse. +- [ ] Move the mouse to the right edge of the hero frame: beam tips about 9°; leave the frame: returns to level. +- [ ] Booking-card finale: beam tipped (−12°) at the top of the page, level at the bottom. +- [ ] macOS "reduce motion" on: every balance level (`rotate(0 200 110)`), nothing animates. +- [ ] Click About, then back to Home three times (wire:navigate): hero balance still renders and settles; finale still levels. +- [ ] Two balances on one page: no duplicate ids (`document.querySelectorAll('[id]')`). diff --git a/docs/tasks/statuses.md b/docs/tasks/statuses.md index 2509a00..c9e31dd 100644 --- a/docs/tasks/statuses.md +++ b/docs/tasks/statuses.md @@ -139,7 +139,7 @@ | 24 | 24 Public-Site Redesign (PRD 1.4) | Ready for Dev | | 24-1 | 24-1 Public Design Foundation (Stylesheet, Script, Fonts, Layout) | QA Review | | 24-2 | 24-2 Navigation and Footer | QA Review | -| 24-3 | 24-3 Signature Balance Component | Ready for Dev | +| 24-3 | 24-3 Signature Balance Component | QA Review | | 24-4 | 24-4 Home Page Sections (Hero to Latest Articles) | Ready for Dev | | 24-5 | 24-5 Home Booking Card (Compact Booking and Day Strip) | Ready for Dev | | 24-6 | 24-6 Booking Page and Booking Success | Ready for Dev | diff --git a/resources/css/public.css b/resources/css/public.css index 0793c39..c7199e0 100644 --- a/resources/css/public.css +++ b/resources/css/public.css @@ -122,6 +122,8 @@ body.site .foot span{display:block;padding:10px 0} body.site .foot{grid-template-columns:1fr} } +body.site .site-balance{display:block;overflow:visible} + /* ---------- Reveal (hidden only when JavaScript runs) ---------- */ body.site.js [data-reveal]{opacity:0;transform:translateY(28px);transition:opacity .9s var(--ease),transform .9s var(--ease)} body.site.js [data-reveal].in{opacity:1;transform:none} diff --git a/resources/js/public.js b/resources/js/public.js index 39bc672..531fb6d 100644 --- a/resources/js/public.js +++ b/resources/js/public.js @@ -1,7 +1,8 @@ import * as reveal from './site/reveal.js'; import * as progress from './site/progress.js'; +import * as balance from './site/balance.js'; -const modules = [reveal, progress]; +const modules = [reveal, progress, balance]; document.addEventListener('livewire:navigated', () => { document.body.classList.add('js'); diff --git a/resources/js/site/balance.js b/resources/js/site/balance.js new file mode 100644 index 0000000..8e3334d --- /dev/null +++ b/resources/js/site/balance.js @@ -0,0 +1,153 @@ +const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n)); + +let uid = 0; +let cleanups = []; +let frames = new Set(); + +function build(svg) { + const i = ++uid; + svg.innerHTML = ` + + + + + + + + + + + + + + + + + + + + + ${[0,1].map(k=>` + + + + + `).join('')} + `; + const beam = svg.querySelector('.beam'); + const pans = svg.querySelectorAll('.pan'); + + return function tilt(deg) { + const r = deg * Math.PI / 180; + const c = Math.cos(r) * 142; + const s = Math.sin(r) * 142; + beam.setAttribute('transform', `rotate(${deg} 200 110)`); + pans[0].setAttribute('transform', `translate(${200 - c} ${110 - s})`); + pans[1].setAttribute('transform', `translate(${200 + c} ${110 + s})`); + }; +} + +/* a damped spring, so the beam swings and settles like real brass */ +function spring(tilt, start) { + const st = { a: start, v: 0, target: 0 }; + let raf = 0; + + const step = () => { + frames.delete(raf); + st.v += (st.target - st.a) * 0.028; + st.v *= 0.935; + st.a += st.v; + tilt(st.a); + if (Math.abs(st.v) > 0.002 || Math.abs(st.target - st.a) > 0.01) { + raf = requestAnimationFrame(step); + frames.add(raf); + } else { + raf = 0; + } + }; + + return { + set(target) { + st.target = target; + if (!raf) { + raf = requestAnimationFrame(step); + frames.add(raf); + } + }, + }; +} + +function on(target, type, handler, options) { + target.addEventListener(type, handler, options); + cleanups.push(() => target.removeEventListener(type, handler, options)); +} + +function hero(svg, tilt) { + tilt(14); + const sp = spring(tilt, 14); + const timer = setTimeout(() => sp.set(0), 450); + cleanups.push(() => clearTimeout(timer)); + + const frame = svg.closest('.frame'); + if (!frame || !matchMedia('(pointer:fine)').matches) return; + + const glow = frame.querySelector('.glow'); + const img = frame.querySelector('img'); + + on(frame, 'pointermove', (e) => { + const fr = frame.getBoundingClientRect(); + const x = (e.clientX - fr.left) / fr.width; + sp.set(clamp((x - 0.5) * 2.4, -1, 1) * 9); + if (glow) { + glow.style.left = (e.clientX - fr.left) + 'px'; + glow.style.top = (e.clientY - fr.top) + 'px'; + } + if (img) { + img.style.transform = `scale(1.08) translate(${(e.clientX - fr.left - fr.width / 2) / -40}px, ${(e.clientY - fr.top - fr.height / 2) / -40}px)`; + } + }); + + on(frame, 'pointerleave', () => { + sp.set(0); + if (img) img.style.transform = ''; + }); +} + +function finale(tilt) { + tilt(-12); + const sp = spring(tilt, -12); + + const onScroll = () => { + const max = document.documentElement.scrollHeight - window.innerHeight; + const p = max > 0 ? window.scrollY / max : 1; + const q = clamp((p - 0.7) / 0.25, 0, 1); + sp.set(-12 * (1 - q)); + }; + + on(window, 'scroll', onScroll, { passive: true }); + onScroll(); +} + +export function init() { + const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; + + document.querySelectorAll('svg[data-balance]').forEach((svg) => { + const tilt = build(svg); + const mode = svg.dataset.balance; + + if (reduced || mode === 'static') { + tilt(0); + } else if (mode === 'hero') { + hero(svg, tilt); + } else if (mode === 'finale') { + finale(tilt); + } + }); +} + +export function destroy() { + cleanups.forEach((fn) => fn()); + cleanups = []; + frames.forEach((id) => cancelAnimationFrame(id)); + frames.clear(); +} diff --git a/resources/views/components/site/balance.blade.php b/resources/views/components/site/balance.blade.php new file mode 100644 index 0000000..9c24017 --- /dev/null +++ b/resources/views/components/site/balance.blade.php @@ -0,0 +1,10 @@ +@props(['mode' => 'static', 'label' => null, 'size' => null]) + +merge(['class' => 'site-balance']) }} + data-balance="{{ $mode }}" + viewBox="0 0 400 400" + style="overflow:visible" + @if ($size) width="{{ $size }}" height="{{ $size }}" @endif + @if ($label) role="img" aria-label="{{ $label }}" @else aria-hidden="true" @endif +> diff --git a/tests/Feature/Design/SiteBalanceTest.php b/tests/Feature/Design/SiteBalanceTest.php new file mode 100644 index 0000000..339162c --- /dev/null +++ b/tests/Feature/Design/SiteBalanceTest.php @@ -0,0 +1,40 @@ +blade(''); + + expect($html) + ->toContain('toContain('class="site-balance"') + ->toContain('data-balance="hero"') + ->toContain('viewBox="0 0 400 400"') + ->toContain('role="img"') + ->toContain('aria-label="ميزان العدالة"') + ->not->toContain('aria-hidden'); +}); + +test('balance without a label is hidden from assistive tech and defaults to static', function () { + $html = (string) $this->blade(''); + + expect($html)->toContain('aria-hidden="true"')->toContain('data-balance="static"')->not->toContain('role="img"'); +}); + +test('size sets width and height and attributes pass through', function () { + $html = (string) $this->blade(''); + + expect($html)->toContain('width="120"')->toContain('height="120"')->toContain('x-extra')->toContain('id="b1"'); +}); + +test('balance script is wired into public.js and ids are made unique per instance', function () { + $public = file_get_contents(resource_path('js/public.js')); + $balance = file_get_contents(resource_path('js/site/balance.js')); + + expect($public)->toContain("./site/balance.js"); + expect($balance)->toContain('export function init')->toContain('export function destroy') + ->toContain('id="gd${i}"')->toContain('id="sh${i}"')->toContain('++uid') + ->toContain('rotate(${deg} 200 110)')->toContain('* 0.028')->toContain('prefers-reduced-motion'); +}); + +test('balance css is scoped', function () { + expect(file_get_contents(resource_path('css/public.css')))->toContain('body.site .site-balance{display:block;overflow:visible}'); +});