From b7b5933b25880a771929b2d142095352c9db301f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 11 Jun 2026 08:48:57 -0400 Subject: [PATCH] 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. --- PLAN.md | 2 +- product-notes/parallax-image-component.md | 147 ++++++++++++---------- 2 files changed, 84 insertions(+), 65 deletions(-) diff --git a/PLAN.md b/PLAN.md index 00f94e3..266a233 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. --- diff --git a/product-notes/parallax-image-component.md b/product-notes/parallax-image-component.md index abd3eee..15abcb6 100644 --- a/product-notes/parallax-image-component.md +++ b/product-notes/parallax-image-component.md @@ -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("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("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 + `` (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 + `` (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.