docs(parallax): fold in resolved JS-placement and direction decisions
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.
This commit is contained in:
@@ -145,7 +145,7 @@ Reusable presentational components in `DeepDrftShared.Client` (the RCL consumed
|
||||
- **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.
|
||||
- **Constraint:** Both open decisions resolved (Daniel, 2026-06-11), no blockers remaining — TS toolchain added to the shared RCL with source co-located at `DeepDrftShared.Client/Interop/parallax/parallax.ts` → `wwwroot/js/parallax/parallax.js`, served from `_content/DeepDrftShared.Client/…` to both hosts; and parallax direction is exposed as the `InvertDirection` component parameter rather than hardcoded. See product note §6a/§11.1 and §3/§11.2.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# ParallaxImage — reusable scroll-parallax image window (DeepDrftShared.Client)
|
||||
|
||||
Status: spec / awaiting approval. Author: product-designer. Date: 2026-06-11.
|
||||
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.**
|
||||
|
||||
---
|
||||
@@ -15,9 +16,9 @@ viewport, shows the bottom. An optional second image crossfades in on hover (int
|
||||
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**).
|
||||
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
|
||||
@@ -44,6 +45,7 @@ lifecycle owned by Blazor" interop pattern (mirrors how `SpectrumAnalyzer` /
|
||||
| `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
|
||||
@@ -53,29 +55,42 @@ assumption per the brief.
|
||||
|
||||
## 3. Parallax math
|
||||
|
||||
The window element's vertical position in the viewport drives `background-position-y`.
|
||||
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.
|
||||
progress = 1 - (rect.top / viewportH) // ranges roughly [0,1] while in view
|
||||
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 formula vs. the brief's `(element.top / viewport.height) * speed * 100%`:
|
||||
Notes on the two variants 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 -`.
|
||||
- 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.
|
||||
@@ -176,45 +191,42 @@ so the parallax `background-position` math applies uniformly to both:
|
||||
|
||||
## 6. JS / TS interop seam — **and the critical placement question**
|
||||
|
||||
### 6a. The placement problem (load-bearing — needs a Daniel decision)
|
||||
### 6a. The placement problem — **RESOLVED**
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
This is the one thing that must be resolved before implementation. Three options, in order
|
||||
of recommendation:
|
||||
**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.
|
||||
|
||||
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.
|
||||
Concrete placement:
|
||||
|
||||
**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.**
|
||||
- **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
|
||||
|
||||
@@ -231,6 +243,7 @@ project's existing seam.
|
||||
// (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
|
||||
@@ -244,8 +257,8 @@ unregister(handleId: string): void;
|
||||
**What Blazor calls:**
|
||||
|
||||
- `OnAfterRenderAsync(firstRender)`: `import()` the module (once), then
|
||||
`module.invokeVoidAsync("register", _elementRef, { speed, onNaturalHeight })`, store the
|
||||
returned handle.
|
||||
`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
|
||||
@@ -363,14 +376,16 @@ the brief instant before WASM/interactive boot.
|
||||
|
||||
## 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.
|
||||
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
|
||||
@@ -387,14 +402,20 @@ the brief instant before WASM/interactive boot.
|
||||
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.)*
|
||||
- `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:
|
||||
|
||||
- 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).
|
||||
- `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).
|
||||
|
||||
@@ -412,5 +433,3 @@ data layer. This component is self-contained and shares nothing with the playbac
|
||||
- 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