fix: refine responsive sign placement
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# Nightdrive sign corridor follow-up
|
||||
|
||||
Status date: 2026-09-03
|
||||
|
||||
This bounded follow-up corrects two rendered regressions without changing the portfolio content layer or the established sign lifecycle. The supplied captures showed the Experience sign under the left-side Experience card and the navigator sign at only phone-scale on a 692×793 viewport.
|
||||
|
||||
## Implemented contract
|
||||
|
||||
- Experience now owns the right roadside corridor. A wide navigator arrival uses the established right dwell pose beside the left Experience card.
|
||||
- During passive Profile-to-Experience travel, both the outgoing right Profile panel and incoming left Experience panel are visible. Stable lower/nearer right-side poses for wide and short-landscape projections keep the complete face clear of both panels instead of merely changing corridor metadata.
|
||||
- Tall navigator arrivals preserve the existing pose through 480px viewport width. From 480px to 720px, scale and mounting height interpolate with a smoothstep curve from `0.25 / y 3.0` to `0.44 / y 4.5`, then remain capped. The larger sign therefore stays in the world-visible slot between stacked content panels rather than growing underneath one.
|
||||
- Width changes use the existing 320ms responsive reflow. They do not relatch a target, redraw a visible label, duplicate a lifecycle, or allocate geometry, material, or texture resources.
|
||||
- Candidacy, navigation idempotency, 1,400ms minimum dwell, pass-by departure, phone composition, quality behavior, and motion suppression are unchanged.
|
||||
|
||||
## Measured projection
|
||||
|
||||
The authenticated browser instrumentation and an independent production-preview capture agreed on the corrected geometry. Full values are in [`measurements.json`](measurements.json).
|
||||
|
||||
| Scenario | Sign face bounds | Nearby content | Minimum measured separation |
|
||||
| --- | --- | --- | ---: |
|
||||
| 692×793 Profile navigator | 151.56–299.37 × 538.49–579.23; 147.82px wide | Profile bottom 518.38; Experience top 614.38 | 20.12px |
|
||||
| 1440×900 passive Experience | 1032.44–1276.55 × 556.44–619.16; 244.11px wide | Profile bottom 536.28; Experience top 680.28 | 20.16px |
|
||||
| 844×390 passive Experience | 640.50–780.92 × 231.91–266.39; 140.42px wide | Profile bottom 194.41; Experience right 540.14 | 37.50px vertical / 100.36px horizontal |
|
||||
|
||||
Both scenarios exceed the existing 16px opaque-content separation contract. The 692px sign was 88.68px wide before the responsive correction in the deterministic renderer test. The ≤480px branch retains the previous phone pose exactly.
|
||||
|
||||
The complete browser matrix also exposed a 3px Profile-panel graze at 844×390 after Experience moved right. The short-landscape pose was brought lower and nearer, then verified in an isolated authenticated production-camera flow at 844×390. A dedicated browser regression now covers that exact transition without depending on every earlier route in the larger sequential matrix. No safety margin, timeout, or lifecycle assertion was relaxed.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Supplied Experience overlap: [`before/experience-left-overlap.png`](before/experience-left-overlap.png)
|
||||
- Supplied undersized 692×793 navigator: [`before/medium-navigator-too-small.png`](before/medium-navigator-too-small.png)
|
||||
- Corrected 692×793 Profile navigator: [`after/profile-navigator-692x793.png`](after/profile-navigator-692x793.png)
|
||||
- Corrected Experience navigator arrival: [`after/experience-navigation-right-1440x900.png`](after/experience-navigation-right-1440x900.png)
|
||||
- Corrected passive Profile-to-Experience transition: [`after/experience-passive-right-1440x900.png`](after/experience-passive-right-1440x900.png)
|
||||
- Corrected short-landscape passive transition: [`after/experience-passive-right-844x390.png`](after/experience-passive-right-844x390.png)
|
||||
|
||||
## Verification record
|
||||
|
||||
- The production gate passed type checking, 43 unit files / 567 tests, client and SSR builds, prerender validation, and transfer budgets.
|
||||
- The focused sign-controller/world run passed 79 tests in 2 files. The browser specification compiles and discovers the dedicated 692×793 and 844×390 regressions.
|
||||
- The unchanged pass-by departure browser check passed with one worker in 6.3 seconds of test time. The isolated authenticated 692×793, 1440×900, and 844×390 flows produced the measurements and full-resolution captures above with `fontReadyWhenDrawn: true`.
|
||||
- The final privileged full-matrix and authenticated-allocation launches could not start because the host approval service returned HTTP 404 before execution. A restricted retry was invalid because Chromium terminated at launch with `sandbox_host_linux ... Operation not permitted`; none of those instant launch failures are represented as portfolio failures or passes.
|
||||
- The patch creates no geometry, material, texture, render-target, or scene-graph objects. The most recent authenticated High-tier sign-system trace therefore remains the applicable allocation baseline: 5,608,790 texture bytes, 755,916 geometry bytes, 161,344,784 render-target bytes, a 33,554,432-byte allowance, and 201,263,922 total accounted bytes. The current transfer-only gate reported no violations.
|
||||
- Deployment verification remains outside this sign fix: 5/9 checks passed in the restricted run, two security-evidence checks stopped at the repository's expired evidence timestamp, and two HTTP-fixture checks could not bind localhost in the sandbox. No assertion, timeout, security timestamp, or budget was changed.
|
||||
|
||||
## Ownership
|
||||
|
||||
No Blender work or new asset was justified. The authored finite car remains Blender-owned and unchanged. Dynamic text, sign behavior, responsive placement, road, terrain, atmosphere, sun, camera, and motion remain Three.js-owned.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 343 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 337 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 973 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 563 KiB |
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"schema": "portfolio-sign-corridor-followup/v1",
|
||||
"capturedAt": "2026-09-03",
|
||||
"scenarios": {
|
||||
"mediumNavigator": {
|
||||
"viewport": { "width": 692, "height": 793 },
|
||||
"targetId": "profile",
|
||||
"phase": "dwell",
|
||||
"navigationArrival": true,
|
||||
"corridor": "left",
|
||||
"bounds": {
|
||||
"left": 151.55509697462335,
|
||||
"top": 538.491415747041,
|
||||
"right": 299.37037784096344,
|
||||
"bottom": 579.225072939037,
|
||||
"width": 147.8152808663401,
|
||||
"height": 40.73365719199603
|
||||
},
|
||||
"panels": {
|
||||
"profileBottom": 518.375,
|
||||
"experienceTop": 614.375
|
||||
},
|
||||
"rendererPixelRatio": 1,
|
||||
"texture": {
|
||||
"width": 2048,
|
||||
"height": 512,
|
||||
"anisotropy": 8,
|
||||
"fontReadyWhenDrawn": true
|
||||
}
|
||||
},
|
||||
"experiencePassive": {
|
||||
"viewport": { "width": 1440, "height": 900 },
|
||||
"targetId": "experience",
|
||||
"phase": "dwell",
|
||||
"navigationArrival": false,
|
||||
"corridor": "right",
|
||||
"bounds": {
|
||||
"left": 1032.4355218074836,
|
||||
"top": 556.4392416287164,
|
||||
"right": 1276.55006451764,
|
||||
"bottom": 619.1620645151419,
|
||||
"width": 244.11454271015646,
|
||||
"height": 62.72282288642543
|
||||
},
|
||||
"panels": {
|
||||
"profileBottom": 536.28125,
|
||||
"experienceTop": 680.28125
|
||||
},
|
||||
"rendererPixelRatio": 1,
|
||||
"texture": {
|
||||
"width": 2048,
|
||||
"height": 512,
|
||||
"anisotropy": 8,
|
||||
"fontReadyWhenDrawn": true
|
||||
}
|
||||
},
|
||||
"experiencePassiveShortLandscape": {
|
||||
"viewport": { "width": 844, "height": 390 },
|
||||
"targetId": "experience",
|
||||
"activeSectionId": "profile",
|
||||
"phase": "dwell",
|
||||
"navigationArrival": false,
|
||||
"corridor": "right",
|
||||
"bounds": {
|
||||
"left": 640.4984,
|
||||
"top": 231.907,
|
||||
"right": 780.9186,
|
||||
"bottom": 266.3877,
|
||||
"width": 140.4202,
|
||||
"height": 34.4807
|
||||
},
|
||||
"panels": {
|
||||
"profileBottom": 194.4063,
|
||||
"experienceRight": 540.1406
|
||||
},
|
||||
"clearance": {
|
||||
"belowProfile": 37.5008,
|
||||
"rightOfExperience": 100.3578
|
||||
},
|
||||
"rendererPixelRatio": 1,
|
||||
"texture": {
|
||||
"width": 2048,
|
||||
"height": 512,
|
||||
"anisotropy": 8,
|
||||
"fontReadyWhenDrawn": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ Navigator buttons add one explicit sign intent to their navigation epoch. That i
|
||||
| --- | --- | ---: | --- | --- |
|
||||
| `start` | Start | 01 | none | deliberately ineligible |
|
||||
| `profile` | Profile | 02 | left | opposite the right Profile card |
|
||||
| `experience` | Experience | 03 | left | clears both the outgoing Profile card and incoming Career surface |
|
||||
| `experience` | Experience | 03 | right | navigator arrivals sit opposite the Career card; the passive Profile transition uses a lower/nearer right pose to clear both visible panels |
|
||||
| `infrastructure` | Infrastructure | 04 | outer-right | outside the broad systems-content corridor, with a route-owned 1.6-world-unit inward dwell bias to preserve the viewport edge |
|
||||
| `selected-builds` | Selected builds | 05 | right | inside the visible right roadside corridor, clear of the large left project card |
|
||||
| `additional-work` | Additional work | 06 | outer-left | outside the outgoing centered Selected builds surface |
|
||||
|
||||
@@ -151,7 +151,7 @@ The controller now consumes a navigator request exactly once through a monotonic
|
||||
|
||||
Passive candidacy requires enabled motion, forward document direction, positive input, and the canonical upcoming route. The trigger maxima are 0.55vh for wide layouts, 0.23vh for portrait, and 0.38vh for short landscape; an actual boundary crossing wins and reconciles directly to the route reached. A qualifying reverse retreat beyond the trigger plus 0.20vh starts a new traversal and re-arms that route and every later route. Visible/cooldown state carries the traversal generation, preventing stale completion or same-label cooldown races. The clock contract is 300ms anticipate, 650–900ms approach, at least 1,400ms dwell measured from the first actual readable sample, 700ms pass-by departure, and 350ms cooldown. Delayed frames cannot skip dwell, stopping input cannot freeze a live sign, reverse input cannot move it physically backward, and a visible label never mutates.
|
||||
|
||||
The final route corridors are Profile left, Experience left, Infrastructure outer-right with a route-owned 1.6-world-unit inward dwell bias, Selected builds right, Additional work outer-left, Toolkit outer-left, and Contact outer-right. Projection assertions cover all seven passive transitions and require the complete dwell face to stay at least 8px inside the viewport, 16px outside visible opaque content, and 8px outside fixed controls. The responsive renderer—not stale lifecycle metadata—owns the current wide/tall/short pose. Visible resize and passive-to-navigator changes share one bounded pose transition and do not revive, duplicate, or strand the object.
|
||||
The final route corridors are Profile left, Experience right, Infrastructure outer-right with a route-owned 1.6-world-unit inward dwell bias, Selected builds right, Additional work outer-left, Toolkit outer-left, and Contact outer-right. Projection assertions cover all seven passive transitions and require the complete dwell face to stay at least 8px inside the viewport, 16px outside visible opaque content, and 8px outside fixed controls. The responsive renderer—not stale lifecycle metadata—owns the current wide/tall/short pose. Visible resize and passive-to-navigator changes share one bounded pose transition and do not revive, duplicate, or strand the object.
|
||||
|
||||
The refined sign remains stable Three.js-owned geometry: one clipped/beveled housing, inset face panel, backplate, rear rails, paired narrow posts and braces, fasteners, and planted footings. It uses 20 renderable parts, 13 geometries, 7 materials, and one texture. The 4:1 face is a 2048×512 sRGB canvas texture with premultiplied alpha, trilinear mipmaps, linear magnification, up to 8× supported anisotropy, awaited Geist Mono fonts, and a non-tone-mapped face material. It redraws/uploads only when a concealed label changes. Recorded source headroom ranged from 6.06 to 13.54 texels per rendered face pixel across 390×844/DPR 3 Low through 2560×1440 High, and every recorded draw reported `fontReadyWhenDrawn: true`.
|
||||
|
||||
@@ -163,6 +163,12 @@ Two pre-existing harness limitations remain explicit. Deployment verification pa
|
||||
|
||||
No additional Blender asset was justified. The finite authored car remains Blender-owned and unchanged. Dynamic sign text, candidacy, lifecycle, responsive placement, road, terrain, atmosphere, sun, camera, and motion remain Three.js-owned.
|
||||
|
||||
### Experience corridor and medium-width navigator follow-up
|
||||
|
||||
The 2026-09-03 screenshot follow-up corrected two projection defects found after the sign-passing merge. Experience had regressed to the left corridor and was rendered underneath its left-side card. It now owns the right corridor. The passive Profile-to-Experience transition uses stable lower/nearer wide and short-landscape poses because its outgoing right Profile panel and incoming left Experience panel can be visible simultaneously; the 1440×900 dwell face measured 1032.44–1276.55 × 556.44–619.16px, leaving 20.16px to the outgoing panel and 61.12px to the incoming panel. A complete browser run found a separate 3px graze at 844×390. The corrected production-camera frame measured 640.50–780.92 × 231.91–266.39px, leaving 37.50px below Profile and 100.36px right of Experience; a dedicated browser regression now exercises that exact transition without weakening the 16px content margin or lifecycle timing.
|
||||
|
||||
Tall navigator signs had also retained the 390px phone pose throughout the entire portrait family, leaving the 692×793 face only 88.68px wide. The phone pose is now preserved through 480px, then its scale and mounting height interpolate smoothly to a capped medium pose at 720px. At 692×793 the Profile face measured 147.82px wide at 151.56–299.37 × 538.49–579.23px, with 20.12px below Profile and 35.15px above Experience. Browser assertions cover both the projected size and 16px content separation. The correction adds no new render resources or visible-phase texture work. Durable before/after captures and exact measurements are indexed in [`sign-corridor-followup/README.md`](evidence/nightdrive-iteration/sign-corridor-followup/README.md).
|
||||
|
||||
## Direction review checklist
|
||||
|
||||
- [x] Information uses near-black opaque panels rather than neon glass.
|
||||
|
||||
@@ -46,7 +46,7 @@ const routeSpecs: Readonly<Record<SectionId, SignRouteSpec>> = Object.freeze({
|
||||
id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
|
||||
},
|
||||
experience: {
|
||||
id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
|
||||
id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
|
||||
},
|
||||
infrastructure: {
|
||||
id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55, dwellOffsetX: -1.6,
|
||||
|
||||
@@ -35,6 +35,10 @@ const SHORT_CORRIDOR_RELIEF_PER_ASPECT = 20;
|
||||
const SHORT_CORRIDOR_MINIMUM_RELIEF = 12;
|
||||
const SHORT_CORRIDOR_MAXIMUM_RELIEF = 34;
|
||||
const RESPONSIVE_REFLOW_MS = 320;
|
||||
const PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH = 480;
|
||||
const PORTRAIT_NAVIGATION_MEDIUM_WIDTH = 720;
|
||||
const PORTRAIT_NAVIGATION_MEDIUM_SCALE = 0.44;
|
||||
const PORTRAIT_NAVIGATION_MEDIUM_Y = 4.5;
|
||||
|
||||
const SIGN_POSES: Readonly<Record<SignViewportProfile, Readonly<Record<SignCorridor, SignPoseSet>>>> = {
|
||||
wide: {
|
||||
@@ -100,6 +104,13 @@ const PORTRAIT_NAVIGATION_DWELL: Readonly<Record<SignCorridor, SignPose>> = {
|
||||
'outer-right': { x: 2.9, y: 3, z: 8, scale: 0.25 },
|
||||
};
|
||||
|
||||
const EXPERIENCE_PASSIVE_DWELL: Readonly<Partial<Record<SignViewportProfile, SignPose>>> = {
|
||||
wide: { x: 12.8, y: 3.7, z: 0, scale: 0.9 },
|
||||
// The near short pose counterbalances the shared aspect relief while keeping
|
||||
// the projected face in the right-hand gap between both visible panels.
|
||||
short: { x: -7.7, y: 3.3, z: -4, scale: 1.4 },
|
||||
};
|
||||
|
||||
export interface SignWorld {
|
||||
group: SignGroup;
|
||||
inspect(
|
||||
@@ -394,6 +405,8 @@ export function createSign(
|
||||
let activeViewport: SignViewportProfile = 'wide';
|
||||
let pendingAspect = 16 / 9;
|
||||
let activeAspect = 16 / 9;
|
||||
let pendingViewportWidth = 1_440;
|
||||
let activeViewportWidth = 1_440;
|
||||
let activeNavigationArrival = false;
|
||||
let activeCorridor: SignCorridor = 'right';
|
||||
let previousPhase: SignSnapshot['phase'] | null = null;
|
||||
@@ -441,16 +454,35 @@ export function createSign(
|
||||
side: number,
|
||||
lateralWeight: number,
|
||||
routeOffsetX = 0,
|
||||
scale = pose.scale,
|
||||
y = pose.y,
|
||||
): void => {
|
||||
group.position.set(
|
||||
resolvedPoseX(pose, side, lateralWeight, routeOffsetX),
|
||||
pose.y,
|
||||
y,
|
||||
pose.z,
|
||||
);
|
||||
group.scale.setScalar(pose.scale);
|
||||
plantMounting(pose.y, pose.scale);
|
||||
group.scale.setScalar(scale);
|
||||
plantMounting(y, scale);
|
||||
};
|
||||
|
||||
const portraitNavigationWidthProgress = (): number => smoothstep(clamp01(
|
||||
(activeViewportWidth - PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH)
|
||||
/ (PORTRAIT_NAVIGATION_MEDIUM_WIDTH - PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH),
|
||||
));
|
||||
|
||||
const portraitNavigationScale = (phoneScale: number): number => THREE.MathUtils.lerp(
|
||||
phoneScale,
|
||||
PORTRAIT_NAVIGATION_MEDIUM_SCALE,
|
||||
portraitNavigationWidthProgress(),
|
||||
);
|
||||
|
||||
const portraitNavigationY = (phoneY: number): number => THREE.MathUtils.lerp(
|
||||
phoneY,
|
||||
PORTRAIT_NAVIGATION_MEDIUM_Y,
|
||||
portraitNavigationWidthProgress(),
|
||||
);
|
||||
|
||||
const plantMounting = (groupY: number, groupScale: number): void => {
|
||||
const safeScale = Math.max(0.001, groupScale);
|
||||
const footingY = -groupY / safeScale;
|
||||
@@ -476,15 +508,19 @@ export function createSign(
|
||||
const stageResponsiveViewport = (
|
||||
viewport: SignViewportProfile,
|
||||
aspect: number,
|
||||
width: number,
|
||||
): void => {
|
||||
const changed = viewport !== pendingViewport
|
||||
|| Math.abs(aspect - pendingAspect) > 0.001;
|
||||
|| Math.abs(aspect - pendingAspect) > 0.001
|
||||
|| Math.abs(width - pendingViewportWidth) > 0.5;
|
||||
if (changed) stagePoseTransition();
|
||||
pendingViewport = viewport;
|
||||
pendingAspect = aspect;
|
||||
pendingViewportWidth = width;
|
||||
if (previousPhase === null) {
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeViewportWidth = pendingViewportWidth;
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
}
|
||||
@@ -535,13 +571,21 @@ export function createSign(
|
||||
},
|
||||
setViewportAspect(aspect) {
|
||||
const nextAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
|
||||
stageResponsiveViewport(nextAspect < 0.85 ? 'tall' : 'wide', nextAspect);
|
||||
stageResponsiveViewport(
|
||||
nextAspect < 0.85 ? 'tall' : 'wide',
|
||||
nextAspect,
|
||||
pendingViewportWidth,
|
||||
);
|
||||
},
|
||||
setViewportSize(width, height) {
|
||||
const nextAspect = Number.isFinite(width) && Number.isFinite(height) && height > 0
|
||||
? Math.max(1, width) / height
|
||||
: 1;
|
||||
stageResponsiveViewport(signViewportProfileFor(width, height), nextAspect);
|
||||
stageResponsiveViewport(
|
||||
signViewportProfileFor(width, height),
|
||||
nextAspect,
|
||||
Number.isFinite(width) ? Math.max(1, width) : 1,
|
||||
);
|
||||
},
|
||||
update(sign, now) {
|
||||
if (!sign) {
|
||||
@@ -551,6 +595,7 @@ export function createSign(
|
||||
previousPhase = null;
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeViewportWidth = pendingViewportWidth;
|
||||
activeNavigationArrival = false;
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
@@ -569,6 +614,7 @@ export function createSign(
|
||||
// the generation that was just disposed rather than this canvas.
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeViewportWidth = pendingViewportWidth;
|
||||
activeNavigationArrival = navigationArrival;
|
||||
poseTransitionPending = false;
|
||||
poseTransitionStartedAt = null;
|
||||
@@ -584,6 +630,7 @@ export function createSign(
|
||||
} else if (poseTransitionPending) {
|
||||
activeViewport = pendingViewport;
|
||||
activeAspect = pendingAspect;
|
||||
activeViewportWidth = pendingViewportWidth;
|
||||
if (poseTransitionStartedAt === null) {
|
||||
poseTransitionStartedAt = Number.isFinite(now) ? now! : 0;
|
||||
}
|
||||
@@ -596,9 +643,18 @@ export function createSign(
|
||||
activeCorridor = corridor;
|
||||
const poseSet = SIGN_POSES[activeViewport][corridor];
|
||||
const horizon = poseSet.horizon;
|
||||
const dwell = activeViewport === 'tall' && activeNavigationArrival
|
||||
let dwell = activeViewport === 'tall' && activeNavigationArrival
|
||||
? PORTRAIT_NAVIGATION_DWELL[corridor]
|
||||
: poseSet.dwell;
|
||||
if (!activeNavigationArrival && sign.targetId === 'experience') {
|
||||
dwell = EXPERIENCE_PASSIVE_DWELL[activeViewport] ?? dwell;
|
||||
}
|
||||
const dwellScale = activeViewport === 'tall' && activeNavigationArrival
|
||||
? portraitNavigationScale(dwell.scale)
|
||||
: dwell.scale;
|
||||
const dwellY = activeViewport === 'tall' && activeNavigationArrival
|
||||
? portraitNavigationY(dwell.y)
|
||||
: dwell.y;
|
||||
const side = corridor.includes('left') ? -1 : 1;
|
||||
const routeOffsetX = route.dwellOffsetX ?? 0;
|
||||
|
||||
@@ -611,16 +667,16 @@ export function createSign(
|
||||
resolvedPoseX(dwell, side, 1, routeOffsetX),
|
||||
progress,
|
||||
),
|
||||
THREE.MathUtils.lerp(horizon.y, dwell.y, progress),
|
||||
THREE.MathUtils.lerp(horizon.y, dwellY, progress),
|
||||
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
|
||||
);
|
||||
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress));
|
||||
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwellScale, progress));
|
||||
plantMounting(group.position.y, group.scale.x);
|
||||
} else if (sign.phase === 'dwell') {
|
||||
applyPose(dwell, side, 1, routeOffsetX);
|
||||
applyPose(dwell, side, 1, routeOffsetX, dwellScale, dwellY);
|
||||
} else {
|
||||
if (previousPhase !== 'depart') {
|
||||
if (previousPhase === null) applyPose(dwell, side, 1, routeOffsetX);
|
||||
if (previousPhase === null) applyPose(dwell, side, 1, routeOffsetX, dwellScale, dwellY);
|
||||
departureOrigin.copy(group.position);
|
||||
departureScale = group.scale.x;
|
||||
}
|
||||
|
||||
@@ -341,6 +341,53 @@ test('keeps a portrait navigator sign clear of its full-width destination panel'
|
||||
}
|
||||
});
|
||||
|
||||
test('scales a medium portrait navigator sign without changing the phone composition', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact portrait breakpoint comparison is sufficient.');
|
||||
const widths: number[] = [];
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 390, height: 844, minimumWidth: 85, maximumWidth: 110 },
|
||||
{ width: 692, height: 793, minimumWidth: 145, maximumWidth: 210 },
|
||||
] as const) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
|
||||
await clickNavigationLink(page, '#profile');
|
||||
await expect(page.locator('#profile-heading')).toBeFocused();
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
|
||||
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||
candidate.targetId === 'profile' && candidate.phase === 'dwell'
|
||||
));
|
||||
if (frame === undefined) throw new Error(`Missing navigator Profile dwell at ${viewport.width}x${viewport.height}`);
|
||||
|
||||
expect(frame.navigationArrival).toBe(true);
|
||||
expect(frame.viewportProfile).toBe('tall');
|
||||
expect(frame.bounds.width).toBeGreaterThanOrEqual(viewport.minimumWidth);
|
||||
expect(frame.bounds.width).toBeLessThanOrEqual(viewport.maximumWidth);
|
||||
expect(frame.bounds.left).toBeGreaterThanOrEqual(8);
|
||||
expect(frame.bounds.right).toBeLessThanOrEqual(viewport.width - 8);
|
||||
const occupied = await page.locator('#profile, #experience').evaluateAll((elements) => (
|
||||
elements.map((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return { id: element.id, left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
|
||||
}).filter((bounds) => bounds.bottom > 0 && bounds.top < window.innerHeight)
|
||||
));
|
||||
for (const panel of occupied) {
|
||||
expect(rectanglesOverlap(frame.bounds, panel, 16), JSON.stringify({ viewport, frame: frame.bounds, panel }))
|
||||
.toBe(false);
|
||||
}
|
||||
widths.push(frame.bounds.width);
|
||||
}
|
||||
|
||||
expect(widths[1]).toBeGreaterThanOrEqual((widths[0] ?? 0) * 1.45);
|
||||
});
|
||||
|
||||
test('moves an already-readable portrait sign into navigator-safe placement without replaying it', async ({
|
||||
page,
|
||||
distUrl,
|
||||
@@ -605,6 +652,8 @@ test('keeps the upcoming Experience sign clear of both outgoing and incoming pan
|
||||
candidate.targetId === 'experience' && candidate.phase === 'dwell'
|
||||
));
|
||||
if (frame === undefined) throw new Error('Missing readable Experience sign frame');
|
||||
expect(frame.corridor).toBe('right');
|
||||
expect(frame.bounds.left).toBeGreaterThan(720);
|
||||
const panels = await page.locator('#profile, #experience').evaluateAll((elements) => (
|
||||
elements.map((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
@@ -623,6 +672,70 @@ test('keeps the upcoming Experience sign clear of both outgoing and incoming pan
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the short-landscape Experience sign in the right roadside gap', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact short-landscape composition is sufficient.');
|
||||
await page.setViewportSize({ width: 844, height: 390 });
|
||||
await page.goto(`${distUrl}/#profile`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
await expect(page.locator('nav a[href="#profile"]')).toHaveAttribute('aria-current', 'page');
|
||||
const destination = await page.evaluate(() => {
|
||||
const heading = document.getElementById('experience-heading');
|
||||
if (!(heading instanceof HTMLElement)) throw new Error('Missing Experience heading');
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
return Math.max(
|
||||
1,
|
||||
heading.getBoundingClientRect().top + window.scrollY
|
||||
- viewportHeight * (0.35 + 0.38),
|
||||
);
|
||||
});
|
||||
await page.mouse.wheel(0, 1);
|
||||
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.findLast((frame) => frame.targetId === 'experience')?.phase, {
|
||||
intervals: [16],
|
||||
timeout: 5_000,
|
||||
}).toBe('dwell');
|
||||
|
||||
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||
candidate.targetId === 'experience' && candidate.phase === 'dwell'
|
||||
));
|
||||
if (frame === undefined) throw new Error('Missing readable short-landscape Experience sign frame');
|
||||
expect(frame.corridor).toBe('right');
|
||||
expect(frame.viewportProfile).toBe('short');
|
||||
expect(frame.bounds.left).toBeGreaterThan(422);
|
||||
expect(frame.bounds.right).toBeLessThanOrEqual(836);
|
||||
expect(frame.bounds.top).toBeGreaterThanOrEqual(210);
|
||||
expect(frame.bounds.bottom).toBeLessThanOrEqual(300);
|
||||
expect(frame.bounds.width).toBeGreaterThanOrEqual(120);
|
||||
|
||||
const occupied = await page.locator('#profile, #experience, .site-navigation, .drive-controls, .journey-hud')
|
||||
.evaluateAll((elements) => elements.map((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return {
|
||||
id: element.id || element.className,
|
||||
fixed: element.matches('.site-navigation, .drive-controls, .journey-hud'),
|
||||
visible: bounds.bottom > 0 && bounds.top < window.innerHeight,
|
||||
bounds: {
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
right: bounds.right,
|
||||
bottom: bounds.bottom,
|
||||
},
|
||||
};
|
||||
}).filter(({ visible }) => visible));
|
||||
for (const surface of occupied) {
|
||||
expect(
|
||||
rectanglesOverlap(frame.bounds, surface.bounds, surface.fixed ? 8 : 16),
|
||||
String(surface.id),
|
||||
).toBe(false);
|
||||
}
|
||||
expect(frame.texture.fontReadyWhenDrawn).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps every passive transition sign clear of visible opaque panels and fixed controls', async ({
|
||||
page,
|
||||
distUrl,
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('clock-driven sign lifecycle', () => {
|
||||
expect(SIGN_ROUTE_ORDER.map((id) => signRouteFor(id))).toEqual([
|
||||
{ id: 'start', label: 'Start', marker: '01', eligible: false, corridor: null, triggerMaximumVh: 0 },
|
||||
{ id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||
{ id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55, dwellOffsetX: -1.6 },
|
||||
{ id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
||||
{ id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55 },
|
||||
|
||||
@@ -724,7 +724,7 @@ describe("procedural world models", () => {
|
||||
});
|
||||
|
||||
expect(face.userData.textureUpdateCount).toBe(2);
|
||||
expect(sign.inspect(createChaseCamera(16 / 9), 1_440, 900)?.corridor).toBe("left");
|
||||
expect(sign.inspect(createChaseCamera(16 / 9), 1_440, 900)?.corridor).toBe("right");
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
@@ -925,6 +925,48 @@ describe("procedural world models", () => {
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it("grows a medium portrait navigator sign without enlarging the phone pose", () => {
|
||||
const phoneResources = new SceneResourceBag();
|
||||
const phoneSign = createSign(phoneResources);
|
||||
const phoneCamera = createChaseCamera(390 / 844);
|
||||
phoneSign.setViewportSize(390, 844);
|
||||
phoneSign.update({
|
||||
targetId: "profile",
|
||||
phase: "dwell",
|
||||
progress: 1,
|
||||
opacity: 1,
|
||||
navigationArrival: true,
|
||||
});
|
||||
const phone = phoneSign.inspect(phoneCamera, 390, 844);
|
||||
if (phone === null) throw new Error("Missing phone navigator sign");
|
||||
|
||||
const mediumResources = new SceneResourceBag();
|
||||
const mediumSign = createSign(mediumResources);
|
||||
const mediumCamera = createChaseCamera(692 / 793);
|
||||
mediumSign.setViewportSize(692, 793);
|
||||
mediumSign.update({
|
||||
targetId: "profile",
|
||||
phase: "dwell",
|
||||
progress: 1,
|
||||
opacity: 1,
|
||||
navigationArrival: true,
|
||||
});
|
||||
const medium = mediumSign.inspect(mediumCamera, 692, 793);
|
||||
if (medium === null) throw new Error("Missing medium navigator sign");
|
||||
|
||||
expect(phone.bounds.width).toBeGreaterThanOrEqual(85);
|
||||
expect(phone.bounds.width).toBeLessThanOrEqual(110);
|
||||
expect(medium.viewportProfile).toBe("tall");
|
||||
expect(medium.bounds.width).toBeGreaterThanOrEqual(145);
|
||||
expect(medium.bounds.width).toBeLessThanOrEqual(210);
|
||||
expect(medium.bounds.width).toBeGreaterThanOrEqual(phone.bounds.width * 1.45);
|
||||
expect(medium.bounds.top).toBeLessThanOrEqual(555);
|
||||
expect(medium.bounds.bottom).toBeLessThanOrEqual(590);
|
||||
|
||||
phoneResources.dispose();
|
||||
mediumResources.dispose();
|
||||
});
|
||||
|
||||
it("lands a resized Profile lifecycle in the short-landscape safe corridor", () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const sign = createSign(resources);
|
||||
@@ -961,10 +1003,26 @@ describe("procedural world models", () => {
|
||||
expect(profileBounds.maxX).toBeLessThan(0.08);
|
||||
|
||||
sign.update(null);
|
||||
sign.update({ targetId: "contact", phase: "dwell", progress: 1, opacity: 1 });
|
||||
sign.update({ targetId: "experience", phase: "dwell", progress: 1, opacity: 1 });
|
||||
sign.group.updateMatrixWorld(true);
|
||||
const experienceBounds = projectedBounds(profileFace, camera);
|
||||
const experiencePresentation = sign.inspect(camera, 1_440, 900);
|
||||
expect(experienceBounds.minX).toBeGreaterThan(0);
|
||||
expect(experiencePresentation?.corridor).toBe("right");
|
||||
expect(experiencePresentation?.bounds.top).toBeGreaterThanOrEqual(540);
|
||||
expect(experiencePresentation?.bounds.bottom).toBeLessThanOrEqual(630);
|
||||
|
||||
sign.update(null);
|
||||
sign.setViewportSize(844, 390);
|
||||
sign.update({ targetId: "experience", phase: "dwell", progress: 1, opacity: 1 });
|
||||
const shortCamera = createChaseCamera(844 / 390);
|
||||
shortCamera.position.set(-1.35, 8.6, 22);
|
||||
shortCamera.lookAt(1.6, 4.8, -128);
|
||||
const shortExperience = sign.inspect(shortCamera, 844, 390);
|
||||
expect(shortExperience?.corridor).toBe("right");
|
||||
expect(shortExperience?.bounds.left).toBeGreaterThan(422);
|
||||
expect(shortExperience?.bounds.top).toBeGreaterThanOrEqual(210);
|
||||
expect(shortExperience?.bounds.bottom).toBeLessThanOrEqual(300);
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user