24-3: signature balance component — implemented, QA Review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: 24-3
|
id: 24-3
|
||||||
title: 24-3 Signature Balance Component
|
title: 24-3 Signature Balance Component
|
||||||
status: Ready for Dev
|
status: QA Review
|
||||||
date: 2026-10-31
|
date: 2026-10-31
|
||||||
parent: 24
|
parent: 24
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 `<svg data-balance>`
|
||||||
|
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]')`).
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
| 24 | 24 Public-Site Redesign (PRD 1.4) | Ready for Dev |
|
| 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-1 | 24-1 Public Design Foundation (Stylesheet, Script, Fonts, Layout) | QA Review |
|
||||||
| 24-2 | 24-2 Navigation and Footer | 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-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-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 |
|
| 24-6 | 24-6 Booking Page and Booking Success | Ready for Dev |
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ body.site .foot span{display:block;padding:10px 0}
|
|||||||
body.site .foot{grid-template-columns:1fr}
|
body.site .foot{grid-template-columns:1fr}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.site .site-balance{display:block;overflow:visible}
|
||||||
|
|
||||||
/* ---------- Reveal (hidden only when JavaScript runs) ---------- */
|
/* ---------- 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]{opacity:0;transform:translateY(28px);transition:opacity .9s var(--ease),transform .9s var(--ease)}
|
||||||
body.site.js [data-reveal].in{opacity:1;transform:none}
|
body.site.js [data-reveal].in{opacity:1;transform:none}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import * as reveal from './site/reveal.js';
|
import * as reveal from './site/reveal.js';
|
||||||
import * as progress from './site/progress.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.addEventListener('livewire:navigated', () => {
|
||||||
document.body.classList.add('js');
|
document.body.classList.add('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 = `<defs>
|
||||||
|
<linearGradient id="gd${i}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#F6EAD0"/><stop offset=".42" stop-color="#C9AD84"/><stop offset=".72" stop-color="#A68966"/><stop offset="1" stop-color="#6A5640"/></linearGradient>
|
||||||
|
<linearGradient id="gc${i}" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#6A5640"/><stop offset=".32" stop-color="#EBD9B4"/><stop offset=".55" stop-color="#B89A72"/><stop offset="1" stop-color="#5E4C37"/></linearGradient>
|
||||||
|
<linearGradient id="gb${i}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#F6EAD0"/><stop offset=".35" stop-color="#C4A882"/><stop offset="1" stop-color="#5E4C37"/></linearGradient>
|
||||||
|
<radialGradient id="gh${i}" cx=".35" cy=".3" r=".8"><stop offset="0" stop-color="#FFF6E2"/><stop offset=".5" stop-color="#C4A882"/><stop offset="1" stop-color="#6A5640"/></radialGradient>
|
||||||
|
<filter id="sh${i}" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="8" stdDeviation="7" flood-color="#0d1009" flood-opacity=".45"/></filter>
|
||||||
|
<filter id="bl${i}"><feGaussianBlur stdDeviation="6"/></filter>
|
||||||
|
</defs>
|
||||||
|
<ellipse cx="200" cy="358" rx="120" ry="9" fill="#0d1009" opacity=".55" filter="url(#bl${i})"/>
|
||||||
|
<g filter="url(#sh${i})">
|
||||||
|
<path d="M104 356 H296 L282 336 H118 Z" fill="url(#gc${i})"/><path d="M104 356 H296" stroke="#F6EAD0" stroke-opacity=".5"/>
|
||||||
|
<path d="M138 336 H262 L252 320 H148 Z" fill="url(#gc${i})"/>
|
||||||
|
<rect x="182" y="300" width="36" height="20" rx="4" fill="url(#gc${i})"/>
|
||||||
|
<rect x="191" y="118" width="18" height="184" fill="url(#gc${i})"/>
|
||||||
|
<path d="M196 124 V298" stroke="#FFF6E2" stroke-opacity=".55" stroke-width="1.4"/>
|
||||||
|
<rect x="184" y="128" width="32" height="9" rx="3" fill="url(#gc${i})"/>
|
||||||
|
<path d="M200 92 C186 80 184 64 194 52 C202 64 204 78 200 92Z M200 92 C214 80 216 64 206 52 C198 64 196 78 200 92Z" fill="url(#gd${i})"/>
|
||||||
|
<path d="M200 92 V60" stroke="#6A5640" stroke-width="1.2"/>
|
||||||
|
<g class="beam"><path d="M58 104 Q200 92 342 104 L342 114 Q200 104 58 114 Z" fill="url(#gb${i})"/>
|
||||||
|
<circle cx="58" cy="109" r="7" fill="url(#gh${i})"/><circle cx="342" cy="109" r="7" fill="url(#gh${i})"/></g>
|
||||||
|
<circle cx="200" cy="110" r="16" fill="url(#gh${i})"/><circle cx="200" cy="110" r="6" fill="#5E4C37"/><circle cx="198" cy="108" r="2" fill="#FFF6E2" opacity=".8"/>
|
||||||
|
${[0,1].map(k=>`<g class="pan pan${k}">
|
||||||
|
<path d="M0 0 L-54 118 M0 0 L54 118 M0 0 L0 120" stroke="#C4A882" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="0" cy="0" r="4.5" fill="url(#gh${i})"/>
|
||||||
|
<path d="M-66 118 Q0 182 66 118 Z" fill="url(#gd${i})"/>
|
||||||
|
<path d="M-56 132 Q0 166 40 140" stroke="#FFF6E2" stroke-opacity=".45" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<ellipse cx="0" cy="118" rx="66" ry="7" fill="#EBD9B4" stroke="#8A7555" stroke-width="1"/></g>`).join('')}
|
||||||
|
</g>`;
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@props(['mode' => 'static', 'label' => null, 'size' => null])
|
||||||
|
|
||||||
|
<svg
|
||||||
|
{{ $attributes->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
|
||||||
|
></svg>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
test('hero balance with a label is an img with viewBox and mode', function () {
|
||||||
|
$html = (string) $this->blade('<x-site.balance mode="hero" label="ميزان العدالة" />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('<svg')
|
||||||
|
->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('<x-site.balance />');
|
||||||
|
|
||||||
|
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('<x-site.balance size="120" class="x-extra" id="b1" />');
|
||||||
|
|
||||||
|
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}');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user