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:
@@ -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