b7b5933b25
Resolve two open questions in the ParallaxImage spec: TS toolchain co-located in DeepDrftShared.Client (Interop/parallax -> wwwroot/js), and parallax direction exposed as the InvertDirection parameter. Update PLAN.md 7.1 constraint to reflect no remaining blockers.
436 lines
24 KiB
Markdown
436 lines
24 KiB
Markdown
# ParallaxImage — reusable scroll-parallax image window (DeepDrftShared.Client)
|
|
|
|
Status: spec / both open decisions resolved (§11.1 JS placement, §11.2 direction) — ready for
|
|
implementation. 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 drove the one load-bearing decision in this spec — where the
|
|
JS/TS interop module ships from so *both* hosts can load it — now **resolved** (TS co-located
|
|
in the shared RCL; see §6a).
|
|
|
|
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). |
|
|
| `InvertDirection` | `bool` | `false` | When `false` (default): top of image visible on entry, bottom visible when the window reaches the top of the viewport (the corrected formula: `1 - rect.top/viewportH`). When `true`: inverts — bottom of image visible on entry, top visible at viewport top. Passed through to the JS module via the `register` options object (§6b). See §3. |
|
|
| `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`. The
|
|
pan **direction is a component parameter** (`InvertDirection`, §2), passed through to the JS
|
|
module via the `register` options object (§6b). Two formula variants, keyed on that flag:
|
|
|
|
```
|
|
// 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.
|
|
if (!invertDirection) {
|
|
// DEFAULT — top of image visible on entry, bottom visible at viewport top.
|
|
progress = 1 - (rect.top / viewportH)
|
|
} else {
|
|
// INVERTED — bottom of image visible on entry, top visible at viewport top
|
|
// (this is the brief's literal form).
|
|
progress = rect.top / viewportH
|
|
}
|
|
// 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 two variants vs. the brief's `(element.top / viewport.height) * speed * 100%`:
|
|
|
|
- The brief's raw form (`rect.top / viewportH`) pans **down** as the element rises (because
|
|
`rect.top` shrinks) — *bottom visible on entry, top visible at the top of the viewport*.
|
|
That is now the **`InvertDirection = true`** branch.
|
|
- The stated visual intent in the brief is the opposite — *top visible on entry, bottom
|
|
visible at the top of the viewport* — produced by the `1 - (rect.top / viewportH)` form.
|
|
That is the **default (`InvertDirection = false`)** branch.
|
|
- **Resolved (Daniel, 2026-06-11):** rather than hardcode either, the direction is exposed as
|
|
the `InvertDirection` parameter so the consumer chooses. Default is the corrected
|
|
(top-on-entry) form.
|
|
- `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 — **RESOLVED**
|
|
|
|
The component lives in `DeepDrftShared.Client` (RCL, consumed by **both** hosts). The brief
|
|
placed 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**, leaving the CMS host (`DeepDrftManager`) with a component and no script
|
|
behind it (silent no-op in the CMS). That conflict is what this decision resolves.
|
|
|
|
**Resolved (Daniel, 2026-06-11): option 1 with the TypeScript toolchain — TS all the way, no
|
|
plain JS.** `Microsoft.TypeScript.MSBuild` is added to `DeepDrftShared.Client` and the TS
|
|
source is co-located with the component in that RCL. RCL static assets are served from
|
|
`_content/DeepDrftShared.Client/…` in *both* hosts automatically, so this is the only path
|
|
where "both hosts can consume it" is true by construction.
|
|
|
|
Concrete placement:
|
|
|
|
- **TS source:** `DeepDrftShared.Client/Interop/parallax/parallax.ts` (mirrors how
|
|
`DeepDrftPublic` has `Interop/audio/`).
|
|
- **Compiled output:** `DeepDrftShared.Client/wwwroot/js/parallax/parallax.js` (the
|
|
`tsconfig.json` `outDir` for the shared lib).
|
|
- **Loaded by the component** via
|
|
`IJSRuntime.InvokeAsync<IJSObjectReference>("import", "./_content/DeepDrftShared.Client/js/parallax/parallax.js")`.
|
|
- **`tsconfig.json`** added to `DeepDrftShared.Client`, mirroring the one in `DeepDrftPublic`
|
|
(`"module": "ES2022"`, `"target": "ES2020"`), and **must not** be copied to output.
|
|
- **`DeepDrftShared.Client.csproj`** gains
|
|
`<PackageReference Include="Microsoft.TypeScript.MSBuild" Version="5.9.3" />` (same version
|
|
as `DeepDrftPublic.csproj`).
|
|
|
|
This keeps the project's "TS not raw JS" convention intact across the shared RCL rather than
|
|
carving out a plain-JS exception. The two rejected options are recorded below for the trail:
|
|
|
|
- **Option 2 — compile in `DeepDrftPublic`, duplicate/link output into `DeepDrftManager`.**
|
|
Build-time coupling: the CMS would depend on an asset produced by a sibling host and could
|
|
ship a stale or missing copy. Rejected.
|
|
- **Option 3 — `JSImport`/scoped per-host.** Each host owns its own copy; doubles the source.
|
|
Rejected.
|
|
|
|
### 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]
|
|
invertDirection: boolean; // InvertDirection — selects the §3 formula branch
|
|
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, invertDirection, 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) — RESOLVED (Daniel, 2026-06-11).** TS all the way: add
|
|
`Microsoft.TypeScript.MSBuild` to `DeepDrftShared.Client`, author the source at
|
|
`Interop/parallax/parallax.ts`, compile to `wwwroot/js/parallax/parallax.js`, load via
|
|
dynamic `import("./_content/DeepDrftShared.Client/js/parallax/parallax.js")`. The
|
|
public-host-only placement (option 2) and per-host duplication (option 3) were rejected —
|
|
only RCL co-location lets both hosts consume it. See §6a for the full resolution.
|
|
2. **Parallax direction (§3) — RESOLVED (Daniel, 2026-06-11).** Direction is now the
|
|
`InvertDirection` component parameter (§2) rather than a hardcoded formula; the consumer
|
|
chooses. Default (`false`) is the corrected top-on-entry form; `true` is the brief's
|
|
literal form. Passed to the JS module via the `register` options object (§6b).
|
|
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/Interop/parallax/parallax.ts` (§6a) — the scroll/observer module,
|
|
authored in TypeScript.
|
|
- `DeepDrftShared.Client/wwwroot/js/parallax/parallax.js` — compiled output of the above
|
|
(the `tsconfig.json` `outDir`); served from `_content/DeepDrftShared.Client/…` to both hosts.
|
|
- `DeepDrftShared.Client/tsconfig.json` — mirrors `DeepDrftPublic`'s (`"module": "ES2022"`,
|
|
`"target": "ES2020"`); **not** copied to output.
|
|
|
|
Changed:
|
|
|
|
- `DeepDrftShared.Client.csproj` — add
|
|
`<PackageReference Include="Microsoft.TypeScript.MSBuild" Version="5.9.3" />` (same version
|
|
as `DeepDrftPublic.csproj`) plus the TypeScript MSBuild property/`None`-update wiring that
|
|
keeps `tsconfig.json` out of output (mirror `DeepDrftPublic.csproj`). `wwwroot/` is packed
|
|
as static web assets by default for an RCL.
|
|
- 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).
|