--- id: 24-3 title: 24-3 Signature Balance Component status: QA Review date: 2026-10-31 parent: 24 --- ## Description Build the redesign's signature: an engraved-gold balance (scales of justice) rendered as an inline SVG, with three behaviours — `hero` (settles on load, weighed by the pointer), `finale` (comes level as the reader scrolls to the booking card) and `static` (always level). Pages place it in later subtasks (24-4 hero and vision card, 24-5 finale, 24-6 success page). ### Files to create 1. **`resources/views/components/site/balance.blade.php`** — props: `mode` (`hero` | `finale` | `static`, default `static`), `label` (default null), `size` (integer px, default null; when set, the SVG gets `width` and `height` of that many px). Renders `` with `overflow:visible`; when `label` is set: `role="img" aria-label="{{ $label }}"`, otherwise `aria-hidden="true"`. Pass through `$attributes` (class, id, style). 2. **`resources/js/site/balance.js`** — exports `init()` and `destroy()`; imported and wired in `resources/js/public.js` exactly like `reveal.js` (init on `livewire:navigated`, destroy on `livewire:navigating`). It contains: - `build(svg)` — fills the SVG with the markup from `docs/ux-mockup.html` lines 566–595 (the `scales()` function's template: four gradients `gd`, `gc`, `gb`, `gh`, two filters `sh` (drop shadow dy 8, stdDeviation 7, flood `#0d1009` opacity .45) and `bl` (blur 6), the ground shadow ellipse, base, pillar, olive-leaf finial, the `.beam` group, the pivot, and two `.pan` groups). Gradient and filter ids must be unique per instance (suffix a running counter) so several balances on one page do not share ids. Returns `tilt(deg)` (mockup lines 597–602): `beam.setAttribute('transform', rotate(deg 200 110))`; with `r = deg·π/180, c = cos(r)·142, s = sin(r)·142`, pan 0 at `translate(200−c, 110−s)` and pan 1 at `translate(200+c, 110+s)`. - `spring(tilt, start)` (mockup lines 605–610): state `a = start, v = 0, target = 0`; each animation frame `v += (target − a)·0.028; v ·= 0.935; a += v; tilt(a)`; keep requesting frames while `|v| > 0.002` or `|target − a| > 0.01`. Exposes `set(target)`. - Behaviour per `data-balance`: - `static`: `tilt(0)`. - `hero`: `tilt(14)`, create a spring from 14, after 450ms `set(0)`. If `matchMedia('(pointer:fine)').matches`, find the closest ancestor `.frame` (the hero frame, built in 24-4); on `pointermove` over it: `x = (clientX − rect.left) / rect.width`, `set(clamp((x − 0.5)·2.4, −1, 1)·9)`; move the frame's `.glow` element's `left`/`top` to the pointer position within the frame; set the frame's `img` transform to `scale(1.08) translate(${(clientX − rect.left − rect.width/2)/−40}px, ${(clientY − rect.top − rect.height/2)/−40}px)`. On `pointerleave`: `set(0)` and clear the image transform. - `finale`: `tilt(−12)`, spring from −12; on a passive window `scroll` listener (and once on init): `p = scrollY / (scrollHeight − innerHeight)` (1 if the denominator is ≤ 0), `q = clamp((p − 0.7) / 0.25, 0, 1)`, `set(−12·(1 − q))`. - If `matchMedia('(prefers-reduced-motion: reduce)').matches`: every balance is built and `tilt(0)`; no spring, no listeners. - `destroy()` removes every listener added and cancels pending animation frames. 3. CSS in **`resources/css/public.css`**: `body.site .site-balance{display:block;overflow:visible}`. ### Files to modify - `resources/js/public.js` (import and wire `./site/balance.js`), `resources/css/public.css`. ## Acceptance Criteria - [ ] `` renders an `` with `data-balance="hero"`, `viewBox="0 0 400 400"`, `role="img"` and the given `aria-label`; without `label` it renders `aria-hidden="true"`. - [ ] With two balances on one page, every `id` inside the SVGs is unique (`document.querySelectorAll('[id]')` has no duplicate values). - [ ] A `hero` balance is drawn tilted on load and its beam's `transform` reaches `rotate(0…)` (|angle| < 0.05) within 4 seconds without pointer input. - [ ] Moving a fine pointer to the right edge of the hero frame tips the beam toward +9° (in the browser, read the beam's transform); leaving the frame returns it toward 0°. - [ ] A `finale` balance's beam is at −12° at the top of the page and within 0.5° of 0° when the page is scrolled to the bottom. - [ ] With `prefers-reduced-motion: reduce` emulated, every balance's beam transform is `rotate(0 200 110)` and no animation frame is requested after load. - [ ] Navigating between public pages with `wire:navigate` and back to `/` re-initialises the hero balance (it renders and settles) and leaves no duplicate scroll listeners (the finale still levels correctly after three navigations). ## Sub-tasks None.