docs: client-approved public-site redesign — PRD 1.4, UX spec v2.0, architecture §6.2, tasks 24-x Ready for Dev

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Naser Mansour
2026-10-31 18:23:02 +02:00
parent caec396fa4
commit 3d1fe40be6
17 changed files with 1761 additions and 5 deletions
+81
View File
@@ -0,0 +1,81 @@
---
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.