3d1fe40be6
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
82 lines
5.0 KiB
Markdown
82 lines
5.0 KiB
Markdown
---
|
||
id: 24-3
|
||
title: 24-3 Signature Balance Component
|
||
status: Ready for Dev
|
||
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
|
||
`<svg class="site-balance" data-balance="{{ $mode }}" viewBox="0 0 400 400" …>` 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
|
||
|
||
- [ ] `<x-site.balance mode="hero" label="ميزان العدالة" />` renders an `<svg>` 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.
|