docs(plan): spec ParallaxImage shared component (Phase 7)
Add product note and PLAN.md Phase 7 entry for a reusable scroll-parallax image window in DeepDrftShared.Client — full-width flag, hover crossfade, IntersectionObserver-gated scroll math, accessibility.
This commit is contained in:
@@ -135,6 +135,20 @@ See `COMPLETED.md` for Phase 6 (§6.1, §6.3) and entity-prep (§6.2 model layer
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Shared UI Components
|
||||
|
||||
Reusable presentational components in `DeepDrftShared.Client` (the RCL consumed by both the public site and the CMS). Distinct from the player stack and CMS surfaces — these are host-agnostic building blocks both apps compose.
|
||||
|
||||
### 7.1 ParallaxImage component
|
||||
|
||||
- **What:** A thin viewport-height container that reveals different portions of an image as the user scrolls — the classic CSS parallax window. As the window scrolls up through the viewport, the image pans through it faster than the page scrolls (top of image on entry, bottom of image by the time the window reaches the top of the viewport). An optional second image crossfades in on hover (intended use: grayscale at rest, colour on hover). A critical `FullWidth` flag stretches the window to `100vw`, breaking out of parent padding. Full signature and design in `product-notes/parallax-image-component.md`.
|
||||
- **Why it matters:** A reusable scroll flourish for hero/section surfaces on both the public site and the CMS, landing the visual identity work without bespoke per-page CSS. It is the first genuinely shared presentational component in `DeepDrftShared.Client` — establishes the pattern (and the RCL static-asset JS-module seam) for shared UI that both hosts consume.
|
||||
- **Shape:** `ParallaxImage.razor` (+ `.razor.cs`, `.razor.css`) in `DeepDrftShared.Client/Components/`. Scroll-driven `background-position` (never `background-attachment: fixed` — broken on iOS Safari), gated by an `IntersectionObserver` so off-screen instances cost nothing. Scroll math lives in a small JS module; lifecycle owned by Blazor via `ElementReference` + an imported `IJSObjectReference`, mirroring the existing audio interop seam. Crossfade is pure CSS. `IAsyncDisposable` tears down the listener. Full parameter table, parallax math, interop contract, full-width breakout technique, accessibility (reduced-motion, alt text), and edge cases (mobile Safari, preload timing) are specified in the product note.
|
||||
- **Prerequisite:** None functionally. Additive — no existing surface changes to adopt it.
|
||||
- **Constraint:** **One blocking decision before implementation** — where the JS module ships from. The component lives in the shared RCL but the brief places the TS at `DeepDrftPublic/Interop/parallax/`, whose compiled JS ships *only* from the public host, leaving the CMS with a no-op component. The product note (§6a/§11.1) recommends co-locating the JS as an RCL static web asset (`DeepDrftShared.Client/wwwroot/js/parallax/parallax.js`, plain ES module, served from `_content/DeepDrftShared.Client/…` to both hosts) instead. A second smaller decision: the spec corrects the sign of the brief's parallax formula to match the stated visual intent (§3/§11.2). Both want a Daniel nod before code lands.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting / not yet themed
|
||||
|
||||
A small set of items that are real but don't fit a phase yet. Surface them when they become relevant rather than committing now.
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
# ParallaxImage — reusable scroll-parallax image window (DeepDrftShared.Client)
|
||||
|
||||
Status: spec / awaiting approval. Author: product-designer. Date: 2026-06-11.
|
||||
**Plan only — no code edits made by this doc.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
A thin viewport-height container that reveals different portions of an image as the
|
||||
user scrolls — the classic CSS "parallax window." As the window scrolls *up* through
|
||||
the viewport, the image pans through the window **faster than the page scrolls**, so the
|
||||
window first shows the top of the image and, by the time it reaches the top of the
|
||||
viewport, shows the bottom. An optional second image crossfades in on hover (intended
|
||||
use: grayscale at rest, colour on hover).
|
||||
|
||||
It lives in `DeepDrftShared.Client` (the shared RCL) so **both the public site and the
|
||||
CMS** can use it. That placement drives the single load-bearing decision in this spec —
|
||||
where the JS/TS interop module ships from so *both* hosts can load it (see §6, the
|
||||
**critical open question**).
|
||||
|
||||
The effect itself is well-trodden prior art (any number of agency landing pages; the
|
||||
canonical reference is the `background-attachment: fixed` parallax, which we deliberately
|
||||
**do not** use — it is broken on iOS Safari and janky on Android). We borrow the *idiom*
|
||||
and implement it the robust way: a scroll-driven `background-position` transform gated by
|
||||
an `IntersectionObserver`, matching the project's existing "math lives in TypeScript,
|
||||
lifecycle owned by Blazor" interop pattern (mirrors how `SpectrumAnalyzer` /
|
||||
`AudioInteropService` already work).
|
||||
|
||||
---
|
||||
|
||||
## 2. Component signature
|
||||
|
||||
`DeepDrftShared.Client/Components/ParallaxImage.razor` (+ `.razor.cs`, `.razor.css`).
|
||||
|
||||
| Parameter | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `Image1` | `string` (required) | — | Primary image URL. Shown at rest. Throws/renders nothing if null or empty. |
|
||||
| `Image2` | `string?` | `null` | Optional hover image. When set, hovering crossfades `Image1`→`Image2`; mouse-out fades back. Assumed same dimensions as `Image1`. |
|
||||
| `Alt1` | `string?` | `null` | Alt text for `Image1`. See accessibility (§9). |
|
||||
| `Alt2` | `string?` | `null` | Alt text for `Image2`. |
|
||||
| `WindowHeight` | `string?` | see §4 | Height of the parallax window. Accepts any CSS length (`"300px"`, `"40vh"`). When null, resolves to the §4 fallback. |
|
||||
| `ImageWidth` | `string` | `"auto"` | `background-size` width. |
|
||||
| `ImageHeight` | `string` | `"auto"` | `background-size` height. |
|
||||
| `FullWidth` | `bool` | `false` | **Critical.** When true, the window stretches to `100vw`, breaking out of parent padding/margins (§5b). When false, it is `100%` of its parent. |
|
||||
| `ParallaxSpeed` | `double` | `0.5` | Multiplier: how much faster the image pans vs. scroll. `0` = static (no parallax), `1` = image moves with full scroll travel. Clamped to `[0,1]` (§4). |
|
||||
| `Class` | `string?` | `null` | Extra CSS classes on the outer window, per the project's existing component convention (`Class` is the house pass-through, see `WaveformSeeker`/`SpectrumVisualizer`). |
|
||||
|
||||
The component does **not** take a separate sizing set for `Image2` — same-dimensions
|
||||
assumption per the brief.
|
||||
|
||||
---
|
||||
|
||||
## 3. Parallax math
|
||||
|
||||
The window element's vertical position in the viewport drives `background-position-y`.
|
||||
|
||||
```
|
||||
// per scroll tick, for an in-view element:
|
||||
rect = element.getBoundingClientRect()
|
||||
viewportH = window.innerHeight
|
||||
// progress: 0 when the window's top is at the bottom of the viewport,
|
||||
// 1 when the window's top reaches the top of the viewport.
|
||||
progress = 1 - (rect.top / viewportH) // ranges roughly [0,1] while in view
|
||||
// clamp so we don't over-pan above/below the in-view band:
|
||||
progress = clamp(progress, 0, 1)
|
||||
// pan the background from top (0%) toward bottom (100%) as progress grows:
|
||||
backgroundPositionY = (progress * speed * 100) + "%"
|
||||
```
|
||||
|
||||
Notes on the formula vs. the brief's `(element.top / viewport.height) * speed * 100%`:
|
||||
|
||||
- The brief's raw form pans **down** as the element rises (because `rect.top` shrinks).
|
||||
The intent stated in the brief is the opposite — *top visible on entry, bottom visible
|
||||
at the top of the viewport*. The `1 - (rect.top / viewportH)` form above produces that
|
||||
intent. **This sign correction is the one place the spec departs from the literal
|
||||
formula in the brief; calling it out so it is a decision, not a silent change.** If
|
||||
Daniel wants the literal direction, drop the `1 -`.
|
||||
- `background-position: 50% Y%` keeps horizontal centred; only Y is driven.
|
||||
- `ParallaxSpeed` is clamped `[0,1]`. Above 1 the image runs out of travel and clamps to
|
||||
the bottom edge early (visible "stick"); below 0 is meaningless. Clamp, don't error.
|
||||
- `background-size` must exceed the window height for there to be anything to pan — i.e.
|
||||
the image is taller than the window (that is the whole premise of the effect). If
|
||||
`ImageHeight`/natural height ≤ `WindowHeight`, there is no pan range; the component
|
||||
still renders (static image), it just has nothing to parallax. Not an error.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sizing & defaults
|
||||
|
||||
- **`WindowHeight` default.** The brief's ideal default is "50% of `Image1` natural
|
||||
height, or `300px` fallback if natural height is unknown." Natural height is **not known
|
||||
at first server render** (no image is loaded yet, and SSR has no DOM). Resolution:
|
||||
- Render with `300px` (the safe fallback) as the initial CSS height.
|
||||
- On image load (`onload` of a hidden probe `<img>`, or the JS module reading
|
||||
`naturalHeight` once the background image decodes), if the consumer did **not** pass an
|
||||
explicit `WindowHeight`, recompute to `naturalHeight * 0.5` and update a CSS custom
|
||||
property. This is a one-time post-load adjustment, gated on "consumer left it default."
|
||||
- If the consumer passed an explicit `WindowHeight`, never override it.
|
||||
- **Trade-off:** the post-load recompute can cause a layout shift (300px → computed) on
|
||||
first paint for default-height usages. Acceptable for the at-rest hero use; if CLS
|
||||
matters for a given placement, the consumer passes an explicit `WindowHeight` and the
|
||||
shift never happens. Document this in the component's XML doc comment.
|
||||
- **`ImageWidth` / `ImageHeight`** map directly to `background-size`. `"auto"` uses
|
||||
natural dimensions. A common real config will be `ImageWidth="100%"` with
|
||||
`ImageHeight="auto"` so the image is as wide as the (possibly full-width) window and
|
||||
tall enough to pan.
|
||||
|
||||
---
|
||||
|
||||
## 5. CSS architecture
|
||||
|
||||
### 5a. Scoped vs global
|
||||
|
||||
- **Scoped (`.razor.css`)** for everything structural: the window box, the layered images,
|
||||
the crossfade transition, the `--parallax-pos` / `--window-height` custom properties.
|
||||
Blazor scoped CSS (`b-{hash}` attribute) keeps this from leaking into either host. This
|
||||
is the default and covers ~all of it.
|
||||
- **No global CSS** should be required. The full-width breakout (§5b) is achievable with
|
||||
scoped CSS + custom properties; it does not need a global rule.
|
||||
- The JS module sets **only** custom properties (`element.style.setProperty('--parallax-pos', …)`),
|
||||
never concrete CSS declarations — so all visual rules stay in the scoped stylesheet and
|
||||
the TS owns *values*, not *style*. Mirrors how `SpectrumVisualizer` drives `--bar-height`.
|
||||
|
||||
### 5b. Full-width breakout (the critical flag)
|
||||
|
||||
When `FullWidth` is true, the window must span the viewport width regardless of parent
|
||||
padding/margins. The robust, well-known technique (no JS needed for the width itself):
|
||||
|
||||
```css
|
||||
.parallax-window.full-width {
|
||||
width: 100vw;
|
||||
position: relative;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
margin-left: -50vw;
|
||||
margin-right: -50vw;
|
||||
}
|
||||
```
|
||||
|
||||
This re-centres a `100vw` element under whatever offset parent it sits in, cancelling
|
||||
ancestor padding. Two caveats to document:
|
||||
|
||||
- **Horizontal scrollbar interaction.** `100vw` includes the scrollbar gutter on some
|
||||
browsers, causing a tiny horizontal overflow. Mitigate with `overflow-x: clip` (or
|
||||
`hidden`) on a layout ancestor, or accept the hairline. Note it; don't solve it inside
|
||||
the component (it is a page-layout concern).
|
||||
- **Nested transformed ancestors.** If an ancestor has a CSS `transform`/`filter`/
|
||||
`perspective`, `position: fixed`-style escapes break — but the `100vw` + negative-margin
|
||||
technique is transform-safe, which is exactly why it is preferred over a fixed-position
|
||||
approach. Good.
|
||||
|
||||
When `FullWidth` is false: plain `width: 100%`, no breakout.
|
||||
|
||||
### 5c. Layered images & crossfade
|
||||
|
||||
Two stacked layers inside the clipped window, both using `background-image` (not `<img>`),
|
||||
so the parallax `background-position` math applies uniformly to both:
|
||||
|
||||
```
|
||||
.parallax-window // overflow:hidden; height: var(--window-height); the clip box
|
||||
.layer.layer-1 // background-image: Image1; opacity: 1
|
||||
.layer.layer-2 // background-image: Image2; opacity: 0 (only if Image2 set)
|
||||
```
|
||||
|
||||
- Both layers share `background-position-y: var(--parallax-pos)` so they pan together.
|
||||
- Crossfade is **pure CSS** (§7): `.parallax-window:hover .layer-2 { opacity: 1 }` with
|
||||
`transition: opacity 400ms ease` on `.layer-2`. Mouse-out reverses automatically — no JS.
|
||||
- When `Image2` is null, `.layer-2` is not rendered at all (no empty layer, no hover cost).
|
||||
- Rationale for `background-image` over a second `<img>`: a single position variable drives
|
||||
both layers; with `<img>` we would need `object-position` plumbing on each. Background
|
||||
layers keep the parallax seam single-sourced. The accessibility cost (background images
|
||||
are invisible to assistive tech) is handled in §9.
|
||||
|
||||
---
|
||||
|
||||
## 6. JS / TS interop seam — **and the critical placement question**
|
||||
|
||||
### 6a. The placement problem (load-bearing — needs a Daniel decision)
|
||||
|
||||
The component lives in `DeepDrftShared.Client` (RCL, consumed by **both** hosts). The brief
|
||||
places the TS module at `DeepDrftPublic/Interop/parallax/parallax.ts`, compiled by
|
||||
`Microsoft.TypeScript.MSBuild` into `DeepDrftPublic/wwwroot/js/`. **That JS ships only from
|
||||
the public host.** The CMS host (`DeepDrftManager`) would have a component with no script
|
||||
behind it — the parallax would silently no-op in the CMS.
|
||||
|
||||
This is the one thing that must be resolved before implementation. Three options, in order
|
||||
of recommendation:
|
||||
|
||||
1. **Co-locate the JS as an RCL static web asset (recommended).** Put the TS/JS *with the
|
||||
component* in `DeepDrftShared.Client/wwwroot/js/parallax/`, loaded as a **JS module via
|
||||
`IJSRuntime.InvokeAsync<IJSObjectReference>("import", "./_content/DeepDrftShared.Client/js/parallax/parallax.js")`**.
|
||||
RCL static assets are served from `_content/DeepDrftShared.Client/…` in *both* hosts
|
||||
automatically. This is the idiomatic Blazor-RCL answer and the only option where "both
|
||||
hosts can consume it" is true by construction.
|
||||
- **Wrinkle:** `DeepDrftShared.Client` would need to host TypeScript compilation
|
||||
(`Microsoft.TypeScript.MSBuild`) itself, which it does not today. Alternatively author
|
||||
it as hand-written ES-module JS in the RCL `wwwroot` (no TS build in the RCL). Given
|
||||
the module is small and self-contained (one scroll handler + one observer), **plain ES
|
||||
module JS in the RCL `wwwroot` is the pragmatic call** — it sidesteps adding a TS
|
||||
toolchain to a second project. The project's "TS not raw JS" convention is specifically
|
||||
about the *audio* bundle; a ~60-line scroll module in the shared RCL is a defensible
|
||||
exception, but flag it to Daniel rather than assume.
|
||||
2. **Compile in `DeepDrftPublic` as the brief says, and duplicate/link the output into
|
||||
`DeepDrftManager`.** Keeps TS authorship in the public host (convention-consistent) but
|
||||
creates a build-time coupling: the CMS depends on an asset produced by a sibling host.
|
||||
Fragile; the CMS could ship a stale or missing copy. Not recommended.
|
||||
3. **`JSImport`/scoped per-host.** Each host owns its own copy of the module. Doubles the
|
||||
source. Worst option; listed only for completeness.
|
||||
|
||||
**Recommendation: option 1, authored as plain ES-module JS in
|
||||
`DeepDrftShared.Client/wwwroot/js/parallax/parallax.js`, loaded via dynamic `import()` from
|
||||
the component.** This is the only path where the RCL is genuinely self-contained for both
|
||||
hosts, and it avoids bolting a TypeScript build onto the shared library. If Daniel wants TS
|
||||
specifically, option 1 with `Microsoft.TypeScript.MSBuild` added to the RCL is the fallback.
|
||||
**The brief's literal `DeepDrftPublic/Interop/parallax/parallax.ts` placement is option 2,
|
||||
which breaks the "both hosts" requirement — surfacing the conflict rather than coding to it.**
|
||||
|
||||
### 6b. Interop contract
|
||||
|
||||
The component holds an `ElementReference` to the window box and an `IJSObjectReference` to
|
||||
the imported module. Lifecycle owned by Blazor; math + listeners owned by JS — exactly the
|
||||
project's existing seam.
|
||||
|
||||
**What the JS module exposes** (ES module exports, invoked via the imported reference):
|
||||
|
||||
```ts
|
||||
// register(element, options) → handle id
|
||||
// Attaches an IntersectionObserver to `element`. While the element is intersecting,
|
||||
// a passive scroll listener updates `--parallax-pos` from the §3 math each frame
|
||||
// (rAF-throttled). While not intersecting, the scroll listener is detached.
|
||||
register(element: HTMLElement, options: {
|
||||
speed: number; // ParallaxSpeed, clamped [0,1]
|
||||
onNaturalHeight?: boolean // if true, module reads the bg image naturalHeight once
|
||||
// decoded and reports it back (for the §4 default)
|
||||
}): string; // returns a handle id
|
||||
|
||||
// unregister(handleId): tears down observer + scroll listener. Called from DisposeAsync.
|
||||
unregister(handleId: string): void;
|
||||
|
||||
// (optional, for §4 default) getNaturalHeight(handleId): number | null
|
||||
```
|
||||
|
||||
**What Blazor calls:**
|
||||
|
||||
- `OnAfterRenderAsync(firstRender)`: `import()` the module (once), then
|
||||
`module.invokeVoidAsync("register", _elementRef, { speed, onNaturalHeight })`, store the
|
||||
returned handle.
|
||||
- If `onNaturalHeight` and the consumer left `WindowHeight` default: read the reported
|
||||
natural height (either returned via a `DotNetObjectReference` callback mirroring
|
||||
`setOnProgressCallback`, or polled once via `getNaturalHeight`) and set
|
||||
`--window-height: {naturalHeight/2}px`. Callback is cleaner; mirror the audio module's
|
||||
`dotNetRef.invokeMethodAsync` pattern.
|
||||
- `DisposeAsync`: `module.invokeVoidAsync("unregister", _handle)` then dispose the module
|
||||
reference. **Must implement `IAsyncDisposable`** — a dangling scroll listener is a real
|
||||
perf leak, and the audit already established `IAsyncDisposable` discipline on the player
|
||||
provider.
|
||||
|
||||
**Performance discipline (non-negotiable in the contract):**
|
||||
|
||||
- Scroll listener is **passive** (`{ passive: true }`) and **rAF-throttled** (one
|
||||
`--parallax-pos` write per frame max, not per scroll event).
|
||||
- The listener is attached **only while the `IntersectionObserver` reports intersecting**.
|
||||
Off-screen instances cost nothing. This is the whole reason the observer is in the
|
||||
contract.
|
||||
- Multiple instances on one page each get their own handle; the module may share a single
|
||||
scroll listener that fans out to all active handles (implementation detail, not contract).
|
||||
|
||||
---
|
||||
|
||||
## 7. Hover crossfade
|
||||
|
||||
Pure CSS, no JS (§5c):
|
||||
|
||||
- `.layer-2` (only rendered when `Image2` set): `opacity: 0; transition: opacity 400ms ease`.
|
||||
- `.parallax-window:hover .layer-2 { opacity: 1; }`.
|
||||
- On mouse-out the transition reverses for free.
|
||||
- **Touch devices have no hover.** On touch, `:hover` may stick or never fire. Acceptable
|
||||
degradation: the at-rest `Image1` shows; `Image2` simply never reveals. Do **not** add a
|
||||
tap-to-toggle in the first cut (scope creep; the intended use is a desktop grayscale→colour
|
||||
flourish). Note it as a known limitation (§10).
|
||||
- The intended grayscale/colour pairing is a **content** choice (consumer supplies a
|
||||
grayscale `Image1` and a colour `Image2`); the component does not apply a CSS `filter:
|
||||
grayscale()`. Keeping it content-driven means the component stays agnostic and the pairing
|
||||
can be any two images, not only desaturate/saturate. (If Daniel would rather the component
|
||||
own the grayscale via `filter` on a single image, that is a different, simpler design —
|
||||
one image, `filter: grayscale(1)` at rest → `grayscale(0)` on hover, no `Image2` at all.
|
||||
Flagged as an alternative in §11.)
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend data / lifecycle flow
|
||||
|
||||
```
|
||||
ParallaxImage rendered (either host)
|
||||
│
|
||||
├─ SSR/first paint: static box at WindowHeight (or 300px fallback), Image1 background,
|
||||
│ --parallax-pos at its progress-0 value. No JS yet. No flash of
|
||||
│ wrong height if WindowHeight was passed explicitly.
|
||||
│
|
||||
└─ OnAfterRenderAsync(firstRender) [interactive]:
|
||||
import _content/DeepDrftShared.Client/js/parallax/parallax.js
|
||||
module.register(elementRef, { speed, onNaturalHeight })
|
||||
│
|
||||
├─ IntersectionObserver gates a passive, rAF-throttled scroll listener
|
||||
│ → writes --parallax-pos each frame while in view
|
||||
│
|
||||
└─ (if default height) reports naturalHeight → component sets --window-height
|
||||
│
|
||||
└─ DisposeAsync: module.unregister(handle); dispose module ref
|
||||
```
|
||||
|
||||
The component renders meaningfully **without** JS (static framed image) — progressive
|
||||
enhancement. The parallax is the enhancement, not the baseline. This matters for SSR and for
|
||||
the brief instant before WASM/interactive boot.
|
||||
|
||||
---
|
||||
|
||||
## 9. Accessibility
|
||||
|
||||
- **`prefers-reduced-motion`.** The parallax pan is decorative motion. When
|
||||
`@media (prefers-reduced-motion: reduce)` is set, the JS module must **not** drive
|
||||
`--parallax-pos` (hold it at the progress-0/static value), and the crossfade transition
|
||||
duration collapses to `0ms` via a scoped media query. The observer/listener can simply not
|
||||
attach under reduced-motion. This is a hard accessibility requirement, not optional.
|
||||
- **Alt text.** Background images are invisible to assistive tech. Provide an accessible name
|
||||
on the window box: render a visually-hidden element (or `role="img"` + `aria-label` on the
|
||||
window) carrying `Alt1`. When `Image2` is purely a decorative hover flourish, it needs no
|
||||
separate alt (the hover is not conveying distinct information). Expose `Alt1`/`Alt2`
|
||||
parameters so the consumer decides; if both are null and the image is decorative, the
|
||||
window gets `role="presentation"` / `aria-hidden` so screen readers skip it cleanly rather
|
||||
than announcing an unnamed image.
|
||||
- **Keyboard / focus.** The component is non-interactive (no seek, no controls); it needs no
|
||||
tab stop. The hover crossfade is decorative, so its absence under keyboard nav is fine.
|
||||
- **Contrast.** If consumers overlay text on the window (a likely hero use), that is the
|
||||
consumer's contrast responsibility; the component does not own overlaid content in this cut
|
||||
(no `ChildContent` slot — see §10 future options).
|
||||
|
||||
---
|
||||
|
||||
## 10. Known edge cases & limitations
|
||||
|
||||
- **Mobile Safari.** The reason we avoid `background-attachment: fixed` entirely — it is
|
||||
broken/disabled on iOS Safari. The scroll-driven `background-position` approach works
|
||||
there. iOS momentum scrolling fires scroll events at frame cadence; the rAF throttle keeps
|
||||
it smooth.
|
||||
- **Image preload / first-paint timing.** The background image may not be decoded at first
|
||||
paint; the window shows its background colour until decode. For the natural-height default
|
||||
(§4) this also means the height recompute waits on decode → possible layout shift. Mitigate
|
||||
by encouraging explicit `WindowHeight` for above-the-fold hero usage; document it.
|
||||
- **Image shorter than window.** No pan range; renders static. Not an error (§3).
|
||||
- **Full-width horizontal overflow.** `100vw` + scrollbar gutter (§5b). A page-layout
|
||||
concern, not solved inside the component.
|
||||
- **Touch / no-hover.** `Image2` never reveals on touch (§7). Accepted limitation.
|
||||
- **Many instances on one page.** Each registers an observer; the shared scroll listener
|
||||
fans out. Verify with a stress page (e.g. 10 windows) before shipping; the in-view gating
|
||||
should keep cost flat.
|
||||
- **SSR.** No DOM at prerender; the component renders its static fallback and enhances after
|
||||
interactive boot. No `getBoundingClientRect` at SSR (would throw); all JS is behind
|
||||
`OnAfterRenderAsync(firstRender)`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Alternatives / open decisions
|
||||
|
||||
1. **JS module placement (§6a) — needs Daniel.** Recommend RCL-co-located plain ES-module JS
|
||||
(`DeepDrftShared.Client/wwwroot/js/parallax/parallax.js`) over the brief's
|
||||
`DeepDrftPublic/Interop/parallax/parallax.ts`, because the literal placement breaks the
|
||||
"both hosts consume it" requirement. Fallback: add `Microsoft.TypeScript.MSBuild` to the
|
||||
RCL and author in TS there. **This is the one blocking decision.**
|
||||
2. **Parallax direction (§3) — needs confirmation.** The spec's `1 - (rect.top/viewportH)`
|
||||
matches the *stated intent* (top-on-entry → bottom-at-top) but inverts the *literal*
|
||||
formula in the brief. Confirm the intent wins.
|
||||
3. **Crossfade model (§7).** `Image2` second-image crossfade (as briefed) vs. a simpler
|
||||
single-image `filter: grayscale()` hover. Briefed design is more flexible (any two
|
||||
images); the filter design is less to wire and needs no second URL. Recommend the briefed
|
||||
two-image design; note the filter alternative exists if the only use is desaturate→colour.
|
||||
4. **`ChildContent` overlay slot — deferred.** A hero window often wants a headline laid over
|
||||
it. Not in this cut. Easy to add later as an optional `RenderFragment ChildContent`
|
||||
absolutely-positioned over the layers. Left out to keep the first cut tight; called out so
|
||||
the markup leaves room (the window box can host an overlay child without restructure).
|
||||
|
||||
---
|
||||
|
||||
## 12. File inventory
|
||||
|
||||
New:
|
||||
|
||||
- `DeepDrftShared.Client/Components/ParallaxImage.razor` (+ `.razor.cs`, `.razor.css`).
|
||||
- `DeepDrftShared.Client/wwwroot/js/parallax/parallax.js` (recommended placement, §6a) —
|
||||
the scroll/observer module. *(Or `DeepDrftPublic/Interop/parallax/parallax.ts` per the
|
||||
brief, pending the §11.1 decision — not recommended.)*
|
||||
|
||||
Changed:
|
||||
|
||||
- Potentially `DeepDrftShared.Client.csproj` — if the JS is an RCL static web asset, confirm
|
||||
`wwwroot/` is packed as static web assets (default for an RCL; usually nothing to change).
|
||||
- Consuming pages in either host that want the effect (no change required to adopt — it is
|
||||
additive).
|
||||
|
||||
Untouched (important): the entire audio TS bundle, the player, the proxy controllers, the
|
||||
data layer. This component is self-contained and shares nothing with the playback path.
|
||||
|
||||
---
|
||||
|
||||
## 13. What this plan deliberately does NOT do
|
||||
|
||||
- Does not use `background-attachment: fixed` (broken on mobile Safari).
|
||||
- Does not add a tab stop / keyboard interaction (decorative, non-interactive).
|
||||
- Does not apply grayscale via CSS `filter` in the first cut — the grayscale/colour pairing
|
||||
is content-driven via `Image1`/`Image2` (the `filter` alternative is recorded in §11.3).
|
||||
- Does not add a `ChildContent` overlay slot in the first cut (§11.4).
|
||||
- Does not drive `--parallax-pos` under `prefers-reduced-motion` (§9).
|
||||
- Does not solve full-width horizontal overflow inside the component (a page-layout concern).
|
||||
- Does not add a TypeScript toolchain to `DeepDrftShared.Client` (the recommended path is
|
||||
plain ES-module JS in the RCL, §6a) — pending the §11.1 decision.
|
||||
Reference in New Issue
Block a user