feat: evolve portfolio into an authored midnight grand tour

This commit is contained in:
2026-09-05 03:55:18 +03:00
parent 642f5b583d
commit c9ac36e249
41 changed files with 812 additions and 65 deletions

View File

@@ -0,0 +1,13 @@
# Grand Tourer — Evolution
Open `nightdrive-evolution.blend` directly to edit the final source. The `Portfolio Evolution` scene contains individually editable bodywork, louvers, wheel vanes, wing and marker housings. The original source in the parent folder was not overwritten. No external assets or paid services were used.
`export-grand-tourer.py` exports the loaded `Portfolio Evolution` scene into eight joined material groups, without lights, cameras, textures or animation. Execute it inside Blender with `__file__` set to the script path. The web renderer sanitizes Blender's duplicate-name suffixes before assigning its runtime lighting behavior.
Design history:
1. The original source scene was appended from `../nightdrive-grand-tourer.blend` and named `Portfolio Evolution`.
2. `refine-grand-tourer.py` applied the first paint, hub, louver and deck-lip pass. This first pass is historical and should not be reapplied to the finished source.
3. `add-personality.py` added the swept wing, mounted cyan guides and turbine wheel faces. This second pass replaces its own `signature-` objects on repeat runs.
To reproduce from the original, follow that order in a separate scene. For ordinary editing, use the finished `.blend` and export script. Asset counts and provenance are recorded in `nightdrive-evolution.asset.json`.

View File

@@ -0,0 +1,66 @@
"""Second design pass; run inside Blender with Portfolio Evolution loaded.
Re-running replaces only this pass's objects. Keeps the eight material groups.
"""
import bpy
import math
from pathlib import Path
root_path = Path(__file__).resolve().parents[3]
scene = bpy.data.scenes['Portfolio Evolution']
bpy.context.window.scene = scene
car = next(o for o in scene.objects if o.name.split('.')[0] == 'chase-car')
materials = {m.name.split('.')[0]: m for o in scene.objects if o.type == 'MESH' for m in o.data.materials}
for obj in list(scene.objects):
if obj.name.startswith('signature-'):
bpy.data.objects.remove(obj, do_unlink=True)
def finish(obj, name, material, bevel=0.008):
obj.name = 'signature-' + name
obj.parent = car
obj.data.materials.append(materials[material])
if bevel:
mod = obj.modifiers.new('Soft machined edge', 'BEVEL')
mod.width = bevel
mod.segments = 2
obj.modifiers.new('Weighted normals', 'WEIGHTED_NORMAL')
return obj
def box(name, location, dimensions, material, bevel=0.008):
bpy.ops.mesh.primitive_cube_add(size=1, location=location)
obj = bpy.context.object
obj.dimensions = dimensions
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
return finish(obj, name, material, bevel)
# A swept floating wing. Its high tips and low center echo the body haunches.
outline = [(-1.22, 2.28), (-0.98, 2.08), (-0.72, 2.13),
(0.72, 2.13), (0.98, 2.08), (1.22, 2.28),
(1.18, 2.51), (0.72, 2.42), (-0.72, 2.42), (-1.18, 2.51)]
n = len(outline)
verts = [(x, y, 1.20 + (0.06 if abs(x) > 0.95 else 0) + dz)
for dz in [0, 0.065] for x, y in outline]
faces = [tuple(reversed(range(n))), tuple(range(n, 2*n))]
faces += [(i, (i+1)%n, (i+1)%n+n, i+n) for i in range(n)]
mesh = bpy.data.meshes.new('Swept aerofoil')
mesh.from_pydata(verts, [], faces)
mesh.update()
wing = bpy.data.objects.new('Swept wing', mesh)
scene.collection.objects.link(wing)
finish(wing, 'floating-wing', 'M_Body_Haunch', 0.018)
for side in [-1, 1]:
box('wing-pylon', (side*0.66, 2.27, 1.045), (0.075, 0.14, 0.35), 'M_Lower_Graphite')
box('wing-endplate', (side*1.185, 2.35, 1.245), (0.045, 0.30, 0.20), 'M_Body_Gunmetal')
box('wing-marker', (side*1.212, 2.37, 1.28), (0.014, 0.19, 0.025), 'M_Cyan_Accent', 0.003)
# Illuminated outer guides frame the dark diffuser, below the coral lamp.
box('rear-guide-housing', (side*0.88, 2.56, 0.50), (0.07, 0.23, 0.235), 'M_Lower_Graphite')
box('rear-guide', (side*0.88, 2.678, 0.50), (0.022, 0.02, 0.19), 'M_Cyan_Accent', 0.004)
for y, z in [(-1.62, 0.42), (1.62, 0.46)]:
# Eight swept metallic vanes sit inside each original rim ring.
for index in range(8):
angle = index * math.tau / 8
obj = box('turbine-vane', (side*1.186, y+math.sin(angle)*0.19, z+math.cos(angle)*0.19),
(0.022, 0.064, 0.19), 'M_Wheel', 0.005)
obj.rotation_euler.x = -angle + side*0.3
bpy.data.libraries.write(str(root_path / 'assets/blender/evolution/nightdrive-evolution.blend'), {scene}, fake_user=True, compress=True)
exec(compile((root_path / 'assets/blender/evolution/export-grand-tourer.py').read_text(), str(root_path / 'assets/blender/evolution/export-grand-tourer.py'), 'exec'))

View File

@@ -0,0 +1,39 @@
"""Export the current authored Portfolio Evolution scene. Run inside Blender."""
import bpy
from pathlib import Path
root_path = Path(__file__).resolve().parents[3]
scene = bpy.data.scenes["Portfolio Evolution"]
# A separate export scene combines geometry by material: eight runtime draw groups.
export_scene = bpy.data.scenes.new('Evolution Export')
bpy.context.window.scene = export_scene
root = bpy.data.objects.new('evolution-chase-car', None)
export_scene.collection.objects.link(root)
names = {'M_Body_Gunmetal': 'car-body', 'M_Body_Haunch': 'body-haunches', 'M_Glass_Smoke': 'rear-glass', 'M_Rear_Light': 'rear-light-bar', 'M_Cyan_Accent': 'car-underglow-source', 'M_Lower_Graphite': 'body-trim', 'M_Wheel': 'wheel-machining', 'M_Tire': 'tires'}
bpy.context.window.scene = scene
depsgraph = bpy.context.evaluated_depsgraph_get()
groups = {}
for obj in scene.objects:
if obj.type != 'MESH' or obj.name.startswith('LOOKDEV'):
continue
evaluated = obj.evaluated_get(depsgraph)
mesh = bpy.data.meshes.new_from_object(evaluated, depsgraph=depsgraph)
mesh.transform(obj.matrix_world)
clone = bpy.data.objects.new('export-' + obj.name, mesh)
export_scene.collection.objects.link(clone)
key = obj.data.materials[0].name.split('.')[0]
groups.setdefault(key, []).append(clone)
bpy.context.window.scene = export_scene
for key, objects in groups.items():
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
if len(objects) > 1:
bpy.ops.object.join()
joined = bpy.context.object
joined.name = 'evolution-' + names[key]
joined.parent = root
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(filepath=str(root_path / 'src/assets/nightdrive-grand-tourer-evolution.glb'), export_format='GLB', use_selection=True, use_active_scene=True, export_apply=True, export_animations=False, export_cameras=False, export_lights=False)
print('Exported', len(groups), 'material groups')
bpy.context.window.scene = scene

View File

@@ -0,0 +1,32 @@
{
"name": "Nightdrive Grand Tourer \u2014 Evolution",
"source": "Derivative refinement of the original portfolio-authored Nightdrive Grand Tourer, with user-authorized Blender modeling",
"license": "Original work",
"thirdPartyAssets": [],
"externalProviders": [],
"sourceFile": "nightdrive-evolution.blend",
"parentSourceFile": "../nightdrive-grand-tourer.blend",
"exportFile": "../../../src/assets/nightdrive-grand-tourer-evolution.glb",
"exportProfile": {
"format": "GLB",
"meshObjects": 8,
"triangles": 18564,
"materials": 8,
"textures": 0,
"animations": 0,
"bytes": 754228
},
"exportSha256": "57c5f4250eab89145e3d7a026d21b12d510e47fade248e481335199d3b963d2d",
"details": [
"Silver-blue metallic paint",
"Recessed wheel hubs and eight-vane turbine wheel faces",
"Six rear-window louvers",
"Low deck lip and swept floating wing with cyan endplate markers",
"Segmented coral tail light and mounted cyan rear guide lamps"
],
"posterSources": [
"src/assets/hero-evolution.jpg",
"src/assets/hero-evolution-mobile.jpg"
],
"posterProvenance": "Canvas-only captures of the real Three.js world at desktop and phone viewport sizes. Decorative fallback only; not project evidence."
}

Binary file not shown.

View File

@@ -0,0 +1,94 @@
"""Run in Blender after loading the original into the isolated Portfolio Evolution scene.
Original scene and other worktrees are never saved or overwritten.
"""
import bpy
from pathlib import Path
root_path = Path(__file__).resolve().parents[3]
scene = bpy.data.scenes['Portfolio Evolution']
bpy.context.window.scene = scene
car = next(o for o in scene.objects if o.name.split('.')[0] == 'chase-car')
materials = {m.name.split('.')[0]: m for o in scene.objects if o.type == 'MESH' for m in o.data.materials}
for name, color, metallic, roughness in [
('M_Body_Gunmetal', (0.14, 0.21, 0.28, 1), 0.78, 0.28),
('M_Body_Haunch', (0.19, 0.28, 0.34, 1), 0.8, 0.24),
('M_Wheel', (0.28, 0.34, 0.39, 1), 0.9, 0.23),
]:
bsdf = materials[name].node_tree.nodes.get('Principled BSDF')
bsdf.inputs['Base Color'].default_value = color
bsdf.inputs['Metallic'].default_value = metallic
bsdf.inputs['Roughness'].default_value = roughness
bsdf.inputs['Coat Weight'].default_value = 0.28 if 'Body' in name else 0
bsdf.inputs['Coat Roughness'].default_value = 0.23
# Recess the old projecting cylinders into the tire face.
for o in scene.objects:
stem = o.name.split('.')[0]
if '-rim-' in stem and '-ring-' not in stem:
o.scale.z *= 0.16
o.location.x = (1 if o.location.x > 0 else -1) * 1.145
if stem.startswith('diffuser-fin-'):
o.scale.z *= 0.65
def box(name, location, dimensions, material, bevel=0.012):
bpy.ops.mesh.primitive_cube_add(size=1, location=location)
obj = bpy.context.object
obj.name = name
obj.dimensions = dimensions
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
obj.parent = car
obj.data.materials.append(material)
mod = obj.modifiers.new('Machined edge', 'BEVEL')
mod.width = bevel
mod.segments = 3
obj.modifiers.new('Weighted normals', 'WEIGHTED_NORMAL')
return obj
# Six individually editable louvers, following the slope of the rear glass.
for index in range(6):
t = index / 5
y = 0.30 + t * 0.74
z = 1.245 - t * 0.275
box('evolution-louver-%02d' % index, (0, y, z), (1.25 + t * 0.18, 0.052, 0.026), materials['M_Lower_Graphite'], 0.008)
box('evolution-deck-lip', (0, 2.07, 0.89), (1.72, 0.095, 0.055), materials['M_Body_Haunch'], 0.018)
# Dark dividers articulate a continuous lamp without increasing emissive energy.
for x in [-0.54, -0.27, 0, 0.27, 0.54]:
box('evolution-lamp-divider', (x, 2.68, 0.694), (0.012, 0.012, 0.047), materials['M_Lower_Graphite'], 0.002)
# Write just this authored scene and its dependencies, not other open projects.
bpy.data.libraries.write(str(root_path / 'assets/blender/evolution/nightdrive-evolution.blend'), {scene}, fake_user=True, compress=True)
# A separate export scene combines geometry by material: eight runtime draw groups.
export_scene = bpy.data.scenes.new('Evolution Export')
bpy.context.window.scene = export_scene
root = bpy.data.objects.new('evolution-chase-car', None)
export_scene.collection.objects.link(root)
names = {'M_Body_Gunmetal': 'car-body', 'M_Body_Haunch': 'body-haunches', 'M_Glass_Smoke': 'rear-glass', 'M_Rear_Light': 'rear-light-bar', 'M_Cyan_Accent': 'car-underglow-source', 'M_Lower_Graphite': 'body-trim', 'M_Wheel': 'wheel-machining', 'M_Tire': 'tires'}
bpy.context.window.scene = scene
depsgraph = bpy.context.evaluated_depsgraph_get()
groups = {}
for obj in scene.objects:
if obj.type != 'MESH' or obj.name.startswith('LOOKDEV'):
continue
evaluated = obj.evaluated_get(depsgraph)
mesh = bpy.data.meshes.new_from_object(evaluated, depsgraph=depsgraph)
mesh.transform(obj.matrix_world)
clone = bpy.data.objects.new('export-' + obj.name, mesh)
export_scene.collection.objects.link(clone)
key = obj.data.materials[0].name.split('.')[0]
groups.setdefault(key, []).append(clone)
bpy.context.window.scene = export_scene
for key, objects in groups.items():
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects[0]
if len(objects) > 1:
bpy.ops.object.join()
joined = bpy.context.object
joined.name = 'evolution-' + names[key]
joined.parent = root
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(filepath=str(root_path / 'src/assets/nightdrive-grand-tourer-evolution.glb'), export_format='GLB', use_selection=True, use_active_scene=True, export_apply=True, export_animations=False, export_cameras=False, export_lights=False)
print('Exported', len(groups), 'material groups')
bpy.context.window.scene = scene

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

View File

@@ -0,0 +1,15 @@
{
"totals": {
"initialHtmlCriticalCss": 33252,
"initialApplicationJs": 90134,
"heroPoster": 189323,
"initialFonts": 69040,
"firstLiveScene": 279010,
"deferredProjectVisuals": 198652,
"highTierExtras": 0,
"deferredNonVideo": 477662,
"deferredAudio": 15307863
},
"violations": [],
"scope": "Transfer-only check. Not runtime performance certification."
}

View File

@@ -0,0 +1,24 @@
# Midnight grand tour
Base: main 642f5b5, containing approved content/media and finished adaptive audio. Isolated branch: codex/gpt6-portfolio-evolution. Existing untracked work stays in the original checkout.
## Visual grammar
A continuous midnight highway, silver-blue bodywork, coral horizon, cyan interface accents. Retain the authored GLB and runtime terrain. Geist remains the self-hosted type family, now with open display composition and quieter supporting labels. Panels use a consistent 16px outer / 8px inner radius; controls use 8px. Keep the dark theme required by the night-drive identity. All approved wording, metadata, media, links, navigation and audio contracts are immutable for this pass, overriding generic design-skill copy and theme rules.
## Scene and document ledger
- Start: open name and equal roles on the left, rear three-quarter vehicle and low coral sun on the right. Lower, longer lens establishes scale.
- Profile: world shifts left to leave a calm reading surface on the right.
- Experience: world shifts right; broad timeline with clear supporting operations grouping.
- Infrastructure: centered panorama behind a wide architectural plate, generous diagram nodes and a distinct operations case.
- Selected builds: broad media-first staging. Portrait screenshots become a carefully spaced three-screen presentation; Footy Tracker receives an evidence-led split composition because no screenshot is approved. All captions and evidence remain readable.
- Additional work: a two-column compact collection instead of another narrow rail.
- Toolkit: horizontal groups with larger recognizable technology marks.
- Contact: camera settles right, warmer horizon, clear destination with unchanged contact actions.
Use render-only camera endpoints and smoothing; never change canonical journey state or audio inputs. Geometry, lights and shader materials remain persistent. Preserve current native scrolling, sign contract, quality governor, resource disposal and fallback.
## Implementation and verification
Work in existing Vue / native CSS / Three.js. No new dependencies. Build on the existing scene and components. Add a focused presentation stylesheet and camera ledger, improve sun/terrain/lighting, and keep semantic markup unchanged wherever possible. Inspect the compiled, validated preview across desktop, tablet, phone and short sizes; exercise navigation, scroll/reversal, resize, static/reduced motion and audio controls. No test edits. Run existing build checks once, then typecheck/client/SSR/prerender/artifact/transfer checks after implementation. Record actual observations and limitations.

View File

@@ -0,0 +1,43 @@
# Midnight Grand Tour — visual review
Implemented on `codex/gpt6-portfolio-evolution`, based on main `642f5b5`, in `.worktrees/gpt6-portfolio-evolution`. Local preview: http://localhost:41812. Ready for visual review; not merged or deployed.
## The visible changes
The opening now combines an open typographic introduction with a longer-lens night drive. A larger coral horizon, broad cyan mountain waves and cooler metallic reflections establish one visual language. Camera compositions ease between chapters without changing native scrolling or the canonical journey/audio state.
The Blender-authored car retains the original wedge body, now with a swept floating wing, turbine-style wheel faces, rear-window louvers, segmented coral lamps and cyan rear guides. The editable source and provenance live in `assets/blender/evolution/`. It exports into eight material groups and 18,564 triangles; reflections are baked once into a small procedural environment, with no per-frame reflection pass.
The professional material has more space: a broad career timeline, an architectural infrastructure plate, larger real project screenshots, an evidence-led Footy Tracker layout, and clearer toolkit groups. Project images open at full size. Controls keep their existing behavior, with refined spacing, focus and touch states. Static and reduced-motion visitors receive desktop or phone artwork captured from the real world.
## Before and after
Desktop before:
![Approved baseline, desktop](evidence/gpt6-evolution/before-desktop.png)
Desktop after:
![Evolution, desktop](evidence/gpt6-evolution/after-desktop.png)
[Phone before](evidence/gpt6-evolution/before-phone.png) · [Phone after](evidence/gpt6-evolution/after-phone.png) · [Tablet opening](evidence/gpt6-evolution/tablet-start.png)
[Project screenshots](evidence/gpt6-evolution/after-work.png) · [Infrastructure](evidence/gpt6-evolution/tablet-infrastructure.png) · [Toolkit](evidence/gpt6-evolution/desktop-toolkit.png) · [Contact](evidence/gpt6-evolution/desktop-contact.png)
[Blender detail render](evidence/gpt6-evolution/blender-coachwork.png) · [Recorded drive, scroll reversal and navigation](evidence/gpt6-evolution/nightdrive-motion.webm)
## Preservation and practical checks
- Normalized rendered main text is identical to the freshly built approved baseline: 12,333 characters. Original content links match. The eight section IDs and their order are unchanged.
- Content, approved metadata, audio source, canonical journey source and test files have no changes. All compiled approved project media and audio files match the baseline byte for byte.
- Final typecheck, client build, server build, validated prerender, artifact validation and transfer-budget check passed. No budget limits were changed. The initial baseline build also ran the required existing unit suite: 662 checks passed; it was not repeatedly rerun during visual iteration. No tests were added or edited.
- Inspected all chapters at desktop 1440×900, tablet 768×1024, phone 390×844, narrow 320×568 and short 1024×600. No horizontal overflow was observed. Exercised menu navigation, scroll, fast jumps, reversal, resizing, keyboard scrolling and focus states.
- Soundtrack reached the running state; mute/unmute, volume at 25%, pause/resume motion and graphics selection worked. Static mode, system reduced motion and a no-JavaScript browser retained content and appropriate poster artwork. Full-size project-image navigation worked.
- The final direct preview produced no JavaScript errors or console warnings, including after graphics-quality changes. Resetting texture-upload flags between renderer generations fixed the observed WebGL warning.
- Final compressed-transfer totals: initial application 90,134 bytes; first live scene including the car 279,010 bytes; both fallback posters together 189,323 bytes. All transfer checks passed. Detailed totals are in `evidence/gpt6-evolution/transfer-budgets.json`.
## Practical limits
The inspection browser reports ANGLE/SwiftShader software rendering. A short direct Low-quality sample averaged about 12 fps at 1440×900 and 40 fps at 390×844; an instrumented High-quality sample was slower. These are software-renderer measurements, not a hardware-GPU performance certification. Existing adaptive quality and Static controls remain available. Vite still reports the existing large deferred Three.js chunk warning; the measured compressed scene transfer remains within its budget.
The recording shows actual browser output and is silent. Audio controls and preservation were checked separately; no claim is made of a complete auditory mix review or physical-device testing.

View File

@@ -6,6 +6,7 @@ import './styles/base.css';
import './styles/layout.css'; import './styles/layout.css';
import './styles/components.css'; import './styles/components.css';
import './styles/world.css'; import './styles/world.css';
import './styles/evolution.css';
import './styles/print.css'; import './styles/print.css';
defineProps<{ inputs: Readonly<ProductionInputs> }>(); defineProps<{ inputs: Readonly<ProductionInputs> }>();

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

View File

@@ -77,6 +77,7 @@ const posterUrl = computed(() => {
:allow-automatic="allowAutomaticVideo" :allow-automatic="allowAutomaticVideo"
/> />
<figure v-else class="project-media project-media--image" data-project-media> <figure v-else class="project-media project-media--image" data-project-media>
<a :href="primaryUrl" target="_blank" rel="noopener noreferrer" :aria-label="presentation.alt">
<img <img
:src="primaryUrl" :src="primaryUrl"
:alt="presentation.alt" :alt="presentation.alt"
@@ -85,6 +86,7 @@ const posterUrl = computed(() => {
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
</a>
<figcaption v-if="presentation.caption">{{ presentation.caption }}</figcaption> <figcaption v-if="presentation.caption">{{ presentation.caption }}</figcaption>
<p v-if="presentation.longDescription" class="media-description" data-testid="media-long-description"> <p v-if="presentation.longDescription" class="media-description" data-testid="media-long-description">
{{ presentation.longDescription }} {{ presentation.longDescription }}

View File

@@ -4,6 +4,8 @@ import { useSceneController, type SceneHostState } from '@/composables/useSceneC
import type { JourneySnapshot } from '@/journey/types'; import type { JourneySnapshot } from '@/journey/types';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy'; import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks'; import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
import posterUrl from '@/assets/hero-evolution.jpg';
import mobilePosterUrl from '@/assets/hero-evolution-mobile.jpg';
declare const __PORTFOLIO_TEST_BUILD__: boolean; declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__; const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
@@ -53,16 +55,10 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure');
:data-static-fallback="!showCanvas" :data-static-fallback="!showCanvas"
:data-poster-failed="posterFailed" :data-poster-failed="posterFailed"
> >
<img v-if="!posterFailed" class="journey-poster" alt="" width="1600" height="900" @error="posterFailed = true"> <picture v-if="!posterFailed">
<div v-if="!posterFailed" class="journey-poster-composition"> <source media="(max-width: 767px)" :srcset="mobilePosterUrl">
<span class="journey-poster__stars" /> <img :src="posterUrl" class="journey-poster" alt="" width="1600" height="900" @error="posterFailed = true">
<span class="journey-poster__sun" /> </picture>
<span class="journey-poster__terrain" />
<span class="journey-poster__road" />
<span class="journey-poster__rail journey-poster__rail--left" />
<span class="journey-poster__rail journey-poster__rail--right" />
<span class="journey-poster__car" />
</div>
<canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" /> <canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" />
</div> </div>
<slot /> <slot />

View File

@@ -64,7 +64,7 @@ const productionFactories: PostProcessingFactories = {
createComposer: (renderer) => new EffectComposer(renderer), createComposer: (renderer) => new EffectComposer(renderer),
createRenderPass: (scene, camera) => new RenderPass(scene, camera), createRenderPass: (scene, camera) => new RenderPass(scene, camera),
createBloomPass: () => createBloomPass: () =>
new UnrealBloomPass(new THREE.Vector2(1, 1), 0.54, 0.42, 0.88), new UnrealBloomPass(new THREE.Vector2(1, 1), 0.3, 0.38, 1.05),
createOutputPass: () => new OutputPass(), createOutputPass: () => new OutputPass(),
}; };
@@ -100,9 +100,9 @@ export function createPostProcessor(
setDynamics(acceleration) { setDynamics(acceleration) {
if (disposed) return; if (disposed) return;
const energy = clamp(acceleration, 0, 1); const energy = clamp(acceleration, 0, 1);
bloomPass.strength = 0.54 + energy * 0.22; bloomPass.strength = 0.3 + energy * 0.12;
bloomPass.radius = 0.42 + energy * 0.08; bloomPass.radius = 0.38 + energy * 0.06;
bloomPass.threshold = 0.88 - energy * 0.03; bloomPass.threshold = 1.05 - energy * 0.03;
}, },
render(scene, camera) { render(scene, camera) {
if (disposed) return; if (disposed) return;

View File

@@ -61,19 +61,21 @@ export function createAtmosphere(
void main() { void main() {
float verticalSky = smoothstep(0.08, 0.66, vSkyUv.y); float verticalSky = smoothstep(0.08, 0.66, vSkyUv.y);
vec3 lowerField = mix(uSkyColor, uFogColor, 0.34); vec3 lowerField = mix(uSkyColor, uFogColor, 0.34);
vec3 color = mix(lowerField * 0.72, uSkyColor * 0.48, verticalSky); vec3 color = mix(lowerField * 0.72, uSkyColor * 1.2, verticalSky);
float horizonBand = exp(-pow(abs(vSkyUv.y - 0.47) * 6.2, 2.0)); float horizonBand = exp(-pow(abs(vSkyUv.y - 0.47) * 6.2, 2.0));
float normalizedX = abs(vSkyUv.x - 0.5) * 2.0 * min(uAspect, 2.35) / 2.35; float normalizedX = abs(vSkyUv.x - 0.5) * 2.0 * min(uAspect, 2.35) / 2.35;
float centerGlow = 1.0 - smoothstep(0.18, 1.16, normalizedX); float centerGlow = 1.0 - smoothstep(0.18, 1.16, normalizedX);
color = mix( color = mix(
color, color,
uHazeColor * (0.72 + uWarmth * 0.14), uHazeColor * (0.72 + uWarmth * 0.14),
horizonBand * mix(0.13, 0.42, centerGlow) horizonBand * mix(0.18, 0.4, centerGlow)
); );
float topFalloff = smoothstep(0.54, 1.0, vSkyUv.y); float topFalloff = smoothstep(0.54, 1.0, vSkyUv.y);
float vignette = smoothstep(0.36, 1.18, normalizedX); float vignette = smoothstep(0.36, 1.18, normalizedX);
color *= 1.0 - topFalloff * 0.28 - vignette * 0.08; color *= 1.0 - topFalloff * 0.28 - vignette * 0.08;
gl_FragColor = vec4(color, 1.0); gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
} }
`, `,
depthWrite: false, depthWrite: false,
@@ -94,7 +96,7 @@ export function createAtmosphere(
const material = resources.own('material', new THREE.MeshBasicMaterial({ const material = resources.own('material', new THREE.MeshBasicMaterial({
color: new THREE.Color(night.fog).lerp(new THREE.Color(night.terrainLow), 0.46 + band * 0.12), color: new THREE.Color(night.fog).lerp(new THREE.Color(night.terrainLow), 0.46 + band * 0.12),
transparent: true, transparent: true,
opacity: 0.2 - band * 0.035, opacity: 0.58 - band * 0.1,
depthWrite: true, depthWrite: true,
depthTest: true, depthTest: true,
toneMapped: true, toneMapped: true,
@@ -147,6 +149,8 @@ export function createAtmosphere(
float radius = distance(gl_PointCoord, vec2(0.5)); float radius = distance(gl_PointCoord, vec2(0.5));
float point = 1.0 - smoothstep(0.2, 0.5, radius); float point = 1.0 - smoothstep(0.2, 0.5, radius);
gl_FragColor = vec4(uColor, point * vBrightness * uVisibility); gl_FragColor = vec4(uColor, point * vBrightness * uVisibility);
#include <tonemapping_fragment>
#include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
@@ -210,6 +214,8 @@ export function createAtmosphere(
varying float vTail; varying float vTail;
void main() { void main() {
gl_FragColor = vec4(uColor * (1.0 + uVisibility * 0.45), uVisibility * mix(0.08, 0.62, vTail)); gl_FragColor = vec4(uColor * (1.0 + uVisibility * 0.45), uVisibility * mix(0.08, 0.62, vTail));
#include <tonemapping_fragment>
#include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
@@ -248,8 +254,8 @@ export function createAtmosphere(
skyMaterial.uniforms.uWarmth.value = palette.warmth; skyMaterial.uniforms.uWarmth.value = palette.warmth;
nearHazeMaterial.uniforms.uColor.value.copy(palette.haze); nearHazeMaterial.uniforms.uColor.value.copy(palette.haze);
farHazeMaterial.uniforms.uColor.value.copy(palette.haze); farHazeMaterial.uniforms.uColor.value.copy(palette.haze);
nearHazeMaterial.uniforms.uEnergy.value = 0.16 + palette.warmth * 0.08; nearHazeMaterial.uniforms.uEnergy.value = 0.07 + palette.warmth * 0.05;
farHazeMaterial.uniforms.uEnergy.value = 0.09 + palette.warmth * 0.06; farHazeMaterial.uniforms.uEnergy.value = 0.045 + palette.warmth * 0.04;
for (let index = 0; index < silhouetteMaterials.length; index += 1) { for (let index = 0; index < silhouetteMaterials.length; index += 1) {
silhouetteMaterials[index]?.color silhouetteMaterials[index]?.color
.copy(palette.fog) .copy(palette.fog)
@@ -338,6 +344,8 @@ function createHazeMaterial(
float centerLift = 1.0 - smoothstep(0.12, 1.2, normalizedX); float centerLift = 1.0 - smoothstep(0.12, 1.2, normalizedX);
float horizontal = mix(0.64, 1.0, centerLift); float horizontal = mix(0.64, 1.0, centerLift);
gl_FragColor = vec4(uColor * 1.08, vertical * horizontal * uEnergy); gl_FragColor = vec4(uColor * 1.08, vertical * horizontal * uEnergy);
#include <tonemapping_fragment>
#include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
@@ -414,6 +422,8 @@ function createRoadsideAnchors(
varying float vDepthFade; varying float vDepthFade;
void main() { void main() {
gl_FragColor = vec4(uColor * (0.7 + uEnergy * 0.55), vDepthFade * uEnergy); gl_FragColor = vec4(uColor * (0.7 + uEnergy * 0.55), vDepthFade * uEnergy);
#include <tonemapping_fragment>
#include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,

View File

@@ -1,6 +1,6 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import grandTourerUrl from '@/assets/nightdrive-grand-tourer.glb?url'; import grandTourerUrl from '@/assets/nightdrive-grand-tourer-evolution.glb?url';
import type { SceneResourceBag } from '@/scene/resources'; import type { SceneResourceBag } from '@/scene/resources';
import type { SceneDetailMode } from '@/scene/types'; import type { SceneDetailMode } from '@/scene/types';
@@ -55,7 +55,7 @@ export async function loadGrandTourer(signal: AbortSignal): Promise<AuthoredCarG
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError'); if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
const gltf = await new GLTFLoader().parseAsync(bytes, GRAND_TOURER_URL); const gltf = await new GLTFLoader().parseAsync(bytes, GRAND_TOURER_URL);
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError'); if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
const authored = gltf.scene.getObjectByName('chase-car') ?? gltf.scene; const authored = gltf.scene.getObjectByName('evolution-chase-car') ?? gltf.scene.getObjectByName('chase-car') ?? gltf.scene;
authored.removeFromParent(); authored.removeFromParent();
return authored as AuthoredCarGroup; return authored as AuthoredCarGroup;
} }
@@ -253,6 +253,8 @@ export function createCar(
vec2 centered = (vGlowUv - 0.5) * vec2(1.55, 1.0); vec2 centered = (vGlowUv - 0.5) * vec2(1.55, 1.0);
float glow = 1.0 - smoothstep(0.12, 0.72, length(centered)); float glow = 1.0 - smoothstep(0.12, 0.72, length(centered));
gl_FragColor = vec4(uColor, glow * uOpacity); gl_FragColor = vec4(uColor, glow * uOpacity);
#include <tonemapping_fragment>
#include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
@@ -355,7 +357,9 @@ function createAuthoredCar(
const tailMaterials: AuthoredMaterial[] = []; const tailMaterials: AuthoredMaterial[] = [];
const cyanMaterials: AuthoredMaterial[] = []; const cyanMaterials: AuthoredMaterial[] = [];
authoredModel.traverse((object) => { authoredModel.traverse((object) => {
if (object.name.endsWith('.001')) object.name = object.name.slice(0, -4); if (object.name.startsWith('evolution-')) object.name = object.name.slice('evolution-'.length);
// Three removes Blender's suffix dot when sanitizing imported node names.
object.name = object.name.replace(/\.?\d{3}$/u, '');
if (!object.isMesh || !object.geometry || !object.material) return; if (!object.isMesh || !object.geometry || !object.material) return;
resources.own('geometry', object.geometry); resources.own('geometry', object.geometry);
const materials = Array.isArray(object.material) ? object.material : [object.material]; const materials = Array.isArray(object.material) ? object.material : [object.material];
@@ -367,7 +371,7 @@ function createAuthoredCar(
&& (object.name === 'car-body' || object.name === 'body-haunches') && (object.name === 'car-body' || object.name === 'body-haunches')
) { ) {
material.emissive?.setHex(0x0b2738); material.emissive?.setHex(0x0b2738);
material.emissiveIntensity = 0.58; material.emissiveIntensity = 0.26;
} }
if (material.isMeshStandardMaterial && object.name === 'rear-glass') { if (material.isMeshStandardMaterial && object.name === 'rear-glass') {
material.emissive?.setHex(0x041725); material.emissive?.setHex(0x041725);
@@ -405,6 +409,8 @@ function createAuthoredCar(
vec2 centered = (vGlowUv - 0.5) * vec2(1.7, 1.0); vec2 centered = (vGlowUv - 0.5) * vec2(1.7, 1.0);
float glow = 1.0 - smoothstep(0.08, 0.74, length(centered)); float glow = 1.0 - smoothstep(0.08, 0.74, length(centered));
gl_FragColor = vec4(uColor * 1.35, glow * uOpacity); gl_FragColor = vec4(uColor * 1.35, glow * uOpacity);
#include <tonemapping_fragment>
#include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
@@ -464,7 +470,7 @@ function createAuthoredCar(
group.position.y = baseY - squat + vibration; group.position.y = baseY - squat + vibration;
group.rotation.x = -squat * 0.035; group.rotation.x = -squat * 0.035;
group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018; group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018;
for (const material of tailMaterials) material.emissiveIntensity = 4.4 + response * 3.2; for (const material of tailMaterials) material.emissiveIntensity = 2.2 + response * 1.2;
for (const material of cyanMaterials) material.emissiveIntensity = 2.2 + response * 1.6; for (const material of cyanMaterials) material.emissiveIntensity = 2.2 + response * 1.6;
underglowUniforms.uOpacity.value = 0.09 + safeSpeed * 0.04 + response * 0.075; underglowUniforms.uOpacity.value = 0.09 + safeSpeed * 0.04 + response * 0.075;
contact.uniforms.uOpacity.value = 0.21 + response * 0.055; contact.uniforms.uOpacity.value = 0.21 + response * 0.055;
@@ -497,6 +503,8 @@ function createContactShadow(
vec2 centered = (vShadowUv - 0.5) * vec2(1.65, 1.0); vec2 centered = (vShadowUv - 0.5) * vec2(1.65, 1.0);
float shadow = 1.0 - smoothstep(0.16, 0.72, length(centered)); float shadow = 1.0 - smoothstep(0.16, 0.72, length(centered));
gl_FragColor = vec4(vec3(0.002, 0.004, 0.008), shadow * uOpacity); gl_FragColor = vec4(vec3(0.002, 0.004, 0.008), shadow * uOpacity);
#include <tonemapping_fragment>
#include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,

View File

@@ -0,0 +1,36 @@
import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
/** Small procedural reflection environment, baked once. No runtime reflection pass.
* Broad sky cards describe the roof; narrow side cards describe the shoulder bevels.
*/
export function createCoachworkEnvironment(resources: SceneResourceBag, renderer: unknown) {
const studio = new THREE.Scene();
studio.background = new THREE.Color(0x172738);
const geometry = new THREE.PlaneGeometry(1, 1);
const cards = [
{ color: 0xb6dce8, power: 3, at: [-8, 7, 2], size: [4, 14] },
{ color: 0x607c91, power: 1.5, at: [6, 5, -3], size: [5, 12] },
{ color: 0xcbd8df, power: 2.2, at: [0, 12, 0], size: [14, 6] },
{ color: 0xbc7278, power: 1.2, at: [0, 3, -12], size: [20, 4] },
];
const materials = cards.map((card) => {
const material = new THREE.MeshBasicMaterial({ color: new THREE.Color(card.color).multiplyScalar(card.power), side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...card.at);
mesh.scale.set(card.size[0], card.size[1], 1);
mesh.lookAt(0, 0, 0);
studio.add(mesh);
return material;
});
const generator = new THREE.PMREMGenerator(renderer);
try {
const target = resources.own('target', generator.fromScene(studio, 0, 0.1, 40));
return target.texture;
} finally {
generator.dispose();
geometry.dispose();
materials.forEach((material) => material.dispose());
studio.clear();
}
}

View File

@@ -0,0 +1,23 @@
import type { SectionId } from '@/content/types';
/** Render-only direction. No values from this ledger are sent to the journey or audio. */
export interface ChapterComposition {
x: number;
y: number;
z: number;
targetY: number;
targetZ: number;
fov: number;
shift: number;
}
export const CHAPTER_COMPOSITIONS: Readonly<Record<SectionId, ChapterComposition>> = {
start: { x: -2.6, y: 6.4, z: 21, targetY: 3.9, targetZ: -90, fov: 56, shift: 0.2 },
profile: { x: -3.6, y: 7.4, z: 23, targetY: 4.3, targetZ: -108, fov: 59, shift: -0.23 },
experience: { x: 3.2, y: 8.1, z: 24, targetY: 4.8, targetZ: -120, fov: 61, shift: 0.25 },
infrastructure: { x: -1.8, y: 9.5, z: 26, targetY: 5.2, targetZ: -128, fov: 64, shift: 0 },
'selected-builds': { x: 2.5, y: 7, z: 23, targetY: 4.2, targetZ: -105, fov: 59, shift: 0.04 },
'additional-work': { x: -3.2, y: 7.8, z: 24, targetY: 4.7, targetZ: -110, fov: 61, shift: -0.12 },
toolkit: { x: 0, y: 8.6, z: 25, targetY: 5, targetZ: -120, fov: 62, shift: 0 },
contact: { x: -2.8, y: 5.8, z: 23, targetY: 3.4, targetZ: -95, fov: 55, shift: 0.24 },
};

View File

@@ -15,6 +15,8 @@ import {
HORIZON_FOG_END, HORIZON_FOG_END,
HORIZON_FOG_START, HORIZON_FOG_START,
} from '@/scene/world/metrics'; } from '@/scene/world/metrics';
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
import { createTravelDistance } from '@/scene/world/travel-distance'; import { createTravelDistance } from '@/scene/world/travel-distance';
import { import {
createMutableChapterPalette, createMutableChapterPalette,
@@ -27,9 +29,9 @@ declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__; const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
export function createChaseCamera(aspect = 1) { export function createChaseCamera(aspect = 1) {
const camera = new THREE.PerspectiveCamera(66, aspect, 0.1, 1_200); const camera = new THREE.PerspectiveCamera(56, aspect, 0.1, 1_200);
camera.position.set(0, 8.6, 22); camera.position.set(-2.6, 6.4, 21);
camera.lookAt(0, 4.8, -128); camera.lookAt(0, 3.9, -90);
return camera; return camera;
} }
@@ -70,6 +72,8 @@ export async function createProceduralSceneGeneration(
const scene = new THREE.Scene(); const scene = new THREE.Scene();
scene.name = 'world-root'; scene.name = 'world-root';
scene.environment = createCoachworkEnvironment(context.resources, renderer);
scene.environmentIntensity = 0.85;
const currentPalette = createMutableChapterPalette('deep-night'); const currentPalette = createMutableChapterPalette('deep-night');
const targetPalette = createMutableChapterPalette('deep-night'); const targetPalette = createMutableChapterPalette('deep-night');
const background = currentPalette.sky.clone(); const background = currentPalette.sky.clone();
@@ -121,8 +125,10 @@ export async function createProceduralSceneGeneration(
y: camera.position.y, y: camera.position.y,
z: camera.position.z, z: camera.position.z,
targetX: 0, targetX: 0,
targetY: 4.8, targetY: 3.9,
targetZ: -128, targetZ: -90,
shift: 0.2,
verticalShift: 0,
fov: camera.fov, fov: camera.fov,
roll: 0, roll: 0,
}; };
@@ -145,8 +151,10 @@ export async function createProceduralSceneGeneration(
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
if (lastNow === null) { if (lastNow === null) {
const wideComposition = camera.aspect > 1.18; const wideComposition = camera.aspect > 1.18;
cameraState.x = wideComposition ? -1.35 : 0; cameraState.x = wideComposition ? -2.6 : 0;
cameraState.targetX = wideComposition ? 1.6 : 0; cameraState.targetX = 0;
cameraState.shift = wideComposition ? 0.2 : 0;
cameraState.verticalShift = wideComposition ? 0 : 0.16;
camera.position.set(cameraState.x, cameraState.y, cameraState.z); camera.position.set(cameraState.x, cameraState.y, cameraState.z);
camera.lookAt( camera.lookAt(
cameraState.targetX, cameraState.targetX,
@@ -241,18 +249,22 @@ export async function createProceduralSceneGeneration(
ambient.color.copy(currentPalette.haze).lerp(currentPalette.terrainHigh, 0.26); ambient.color.copy(currentPalette.haze).lerp(currentPalette.terrainHigh, 0.26);
ambient.groundColor.copy(currentPalette.fog); ambient.groundColor.copy(currentPalette.fog);
ambient.intensity = 1.05 + currentPalette.warmth * 0.18; ambient.intensity = 1.05 + currentPalette.warmth * 0.18;
carKeyLight.color.copy(currentPalette.terrainHigh).lerp(currentPalette.haze, 0.22); carKeyLight.color.setHex(0xb9e3ef);
carKeyLight.intensity = 21 + response.bloomEnergy * 3.6; carKeyLight.intensity = 32 + response.bloomEnergy * 3.6;
carFillLight.color.copy(currentPalette.haze).lerp(currentPalette.sun, 0.18); carFillLight.color.setHex(0x809ec1);
carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8; carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8;
rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4; rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4;
const wideComposition = camera.aspect > 1.18; const wideComposition = camera.aspect > 1.18;
const cameraX = wideComposition ? -1.35 : 0; const composition = CHAPTER_COMPOSITIONS[snapshot.activeSectionId];
const targetX = wideComposition ? 1.6 : 0; // The document stays native. Only the rendered lens eases to the chapter's safe zone.
const cameraX = wideComposition ? composition.x : 0;
const targetX = 0;
const targetShift = wideComposition ? composition.shift : 0;
const targetVerticalShift = wideComposition ? 0 : (snapshot.activeSectionId === 'start' ? 0.16 : 0.07);
const roadVibration = Math.sin(driveDistance * 0.105) * ( const roadVibration = Math.sin(driveDistance * 0.105) * (
0.012 + response.roadEnergy * 0.024 0.012 + response.roadEnergy * 0.024
); );
const cameraMix = 1 - Math.exp(-deltaSeconds / 0.2); const cameraMix = 1 - Math.exp(-deltaSeconds / 0.85);
cameraState.x = THREE.MathUtils.lerp( cameraState.x = THREE.MathUtils.lerp(
cameraState.x, cameraState.x,
cameraX - response.directionBias * 0.16, cameraX - response.directionBias * 0.16,
@@ -260,12 +272,12 @@ export async function createProceduralSceneGeneration(
); );
cameraState.y = THREE.MathUtils.lerp( cameraState.y = THREE.MathUtils.lerp(
cameraState.y, cameraState.y,
8.6 + roadVibration, (wideComposition ? composition.y : 6.5) + roadVibration,
cameraMix, cameraMix,
); );
cameraState.z = THREE.MathUtils.lerp( cameraState.z = THREE.MathUtils.lerp(
cameraState.z, cameraState.z,
22 - response.cameraEnergy * 1.65, (wideComposition ? composition.z : 29) - response.cameraEnergy * 1.2,
cameraMix, cameraMix,
); );
cameraState.targetX = THREE.MathUtils.lerp( cameraState.targetX = THREE.MathUtils.lerp(
@@ -275,17 +287,17 @@ export async function createProceduralSceneGeneration(
); );
cameraState.targetY = THREE.MathUtils.lerp( cameraState.targetY = THREE.MathUtils.lerp(
cameraState.targetY, cameraState.targetY,
4.8 + response.cameraEnergy * 0.58, composition.targetY + response.cameraEnergy * 0.32,
cameraMix, cameraMix,
); );
cameraState.targetZ = THREE.MathUtils.lerp( cameraState.targetZ = THREE.MathUtils.lerp(
cameraState.targetZ, cameraState.targetZ,
-128 - response.cameraEnergy * 24, composition.targetZ - response.cameraEnergy * 16,
cameraMix, cameraMix,
); );
cameraState.fov = THREE.MathUtils.lerp( cameraState.fov = THREE.MathUtils.lerp(
cameraState.fov, cameraState.fov,
66 + response.cameraEnergy * 13, (wideComposition ? composition.fov : 62) + response.cameraEnergy * 7,
cameraMix, cameraMix,
); );
cameraState.roll = THREE.MathUtils.lerp( cameraState.roll = THREE.MathUtils.lerp(
@@ -293,6 +305,9 @@ export async function createProceduralSceneGeneration(
-response.directionBias * 0.003 + roadVibration * 0.018, -response.directionBias * 0.003 + roadVibration * 0.018,
cameraMix, cameraMix,
); );
cameraState.shift = THREE.MathUtils.lerp(cameraState.shift, targetShift, cameraMix);
cameraState.verticalShift = THREE.MathUtils.lerp(cameraState.verticalShift, targetVerticalShift, cameraMix);
camera.setViewOffset(width, height, -width * cameraState.shift, -height * cameraState.verticalShift, width, height);
camera.position.set(cameraState.x, cameraState.y, cameraState.z); camera.position.set(cameraState.x, cameraState.y, cameraState.z);
camera.lookAt(cameraState.targetX, cameraState.targetY, cameraState.targetZ); camera.lookAt(cameraState.targetX, cameraState.targetY, cameraState.targetZ);
camera.rotation.z += cameraState.roll; camera.rotation.z += cameraState.roll;
@@ -327,6 +342,11 @@ export async function createProceduralSceneGeneration(
dispose() { dispose() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
// Quality changes reuse the canvas context. Leave texture upload flags at
// defaults before the next renderer creates its empty array textures.
const gl = renderer.getContext();
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
scene.clear(); scene.clear();
}, },
}; };

View File

@@ -40,10 +40,10 @@ export const CHAPTER_PALETTES: Readonly<Record<EnvironmentChapter, ChapterPalett
'deep-night': { 'deep-night': {
sky: 0x040713, sky: 0x040713,
fog: 0x07101c, fog: 0x07101c,
terrainLow: 0x126a7c, terrainLow: 0x388596,
terrainHigh: 0xa53a74, terrainHigh: 0xa85e87,
sun: 0xc7437e, sun: 0xe97985,
haze: 0x65225f, haze: 0x69425b,
warmth: 0.12, warmth: 0.12,
terrainEnergy: 0.78, terrainEnergy: 0.78,
starVisibility: 1, starVisibility: 1,

View File

@@ -149,6 +149,8 @@ export function createRoad(
color = mix(color, atmosphericRoad, fog); color = mix(color, atmosphericRoad, fog);
color *= vNearFade; color *= vNearFade;
gl_FragColor = vec4(color, 1.0); gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
} }
`, `,
depthWrite: true, depthWrite: true,

View File

@@ -33,7 +33,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
uTime: { value: 0 }, uTime: { value: 0 },
uDriveEnergy: { value: 0 }, uDriveEnergy: { value: 0 },
}; };
const geometry = resources.own('geometry', new THREE.CircleGeometry(74, 128)); const geometry = resources.own('geometry', new THREE.CircleGeometry(112, 128));
const material = resources.own('material', new THREE.ShaderMaterial({ const material = resources.own('material', new THREE.ShaderMaterial({
uniforms, uniforms,
vertexShader: ` vertexShader: `
@@ -72,16 +72,18 @@ export function createSun(resources: SceneResourceBag): SunWorld {
float scanPhase = fract(vSunUv.y * uScanBands + sin(uTime * 0.08) * 0.008); float scanPhase = fract(vSunUv.y * uScanBands + sin(uTime * 0.08) * 0.008);
float scanBand = smoothstep(0.09, 0.18, scanPhase) float scanBand = smoothstep(0.09, 0.18, scanPhase)
* (1.0 - smoothstep(0.72, 0.82, scanPhase)); * (1.0 - smoothstep(0.72, 0.82, scanPhase));
float bandCut = mix(1.0, 0.54 + scanBand * 0.46, lowerDisc * 0.78); float bandCut = mix(1.0, 0.06 + scanBand * 0.94, lowerDisc);
float horizonFade = smoothstep(0.015, 0.13, vSunUv.y); float horizonFade = smoothstep(0.015, 0.13, vSunUv.y);
float radialCore = 1.0 - smoothstep(0.02, 0.48, radius); float radialCore = 1.0 - smoothstep(0.02, 0.48, radius);
float luminance = verticalHeat float luminance = verticalHeat
* (1.08 + uWarmth * 0.16 + uRouteProgress * 0.045 + uDriveEnergy * 0.045); * (1.08 + uWarmth * 0.16 + uRouteProgress * 0.045 + uDriveEnergy * 0.045);
vec3 warmCore = mix(uColor, vec3(1.0, 0.61, 0.43), uWarmth * 0.18 + uRouteProgress * 0.035); vec3 warmCore = mix(uColor, vec3(1.0, 0.56, 0.32), smoothstep(0.25, 0.92, vSunUv.y) * 0.84);
vec3 core = mix(uColor * 0.78, warmCore * 1.12, radialCore * 0.58); vec3 core = mix(uColor * 0.82, warmCore * 0.83, smoothstep(0.05, 0.86, vSunUv.y));
core = mix(core, core * mix(0.82, 1.13, internalField), 0.54); core = mix(core, core * mix(0.82, 1.13, internalField), 0.54);
core = mix(core, uHazeColor * 0.72, limb * 0.22 + lowerDisc * 0.055); core = mix(core, uHazeColor * 0.72, limb * 0.22 + lowerDisc * 0.055);
gl_FragColor = vec4(core * luminance, softEdge * bandCut * horizonFade); gl_FragColor = vec4(core * luminance, softEdge * bandCut * horizonFade);
#include <tonemapping_fragment>
#include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
@@ -92,7 +94,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
})); }));
const disc = new THREE.Mesh(geometry, material); const disc = new THREE.Mesh(geometry, material);
disc.name = 'horizon-sun'; disc.name = 'horizon-sun';
disc.position.set(0, 48, SUN_DISTANCE_Z); disc.position.set(0, 60, SUN_DISTANCE_Z);
group.add(disc); group.add(disc);
const limbMaterial = resources.own('material', new THREE.ShaderMaterial({ const limbMaterial = resources.own('material', new THREE.ShaderMaterial({
@@ -116,6 +118,8 @@ export function createSun(resources: SceneResourceBag): SunWorld {
float ring = smoothstep(0.405, 0.475, radius) float ring = smoothstep(0.405, 0.475, radius)
* (1.0 - smoothstep(0.475, 0.515, radius)); * (1.0 - smoothstep(0.475, 0.515, radius));
gl_FragColor = vec4(uColor * (0.72 + uEnergy * 0.07), ring * (0.22 + uEnergy * 0.025)); gl_FragColor = vec4(uColor * (0.72 + uEnergy * 0.07), ring * (0.22 + uEnergy * 0.025));
#include <tonemapping_fragment>
#include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,
@@ -182,8 +186,8 @@ export function createSun(resources: SceneResourceBag): SunWorld {
limbMaterial.uniforms.uEnergy.value = energy; limbMaterial.uniforms.uEnergy.value = energy;
haloMaterial.opacity = 0.105 + palette.warmth * 0.05 + energy * 0.015; haloMaterial.opacity = 0.105 + palette.warmth * 0.05 + energy * 0.015;
bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01; bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01;
halo.scale.set(330 + energy * 18, 232 + energy * 10, 1); halo.scale.set(480 + energy * 18, 360 + energy * 10, 1);
disc.position.y = 48 + palette.warmth * 9; disc.position.y = 60 + palette.warmth * 14;
limb.position.y = disc.position.y; limb.position.y = disc.position.y;
halo.position.y = disc.position.y; halo.position.y = disc.position.y;
bleed.position.y = 7 + palette.warmth * 3; bleed.position.y = 7 + palette.warmth * 3;

View File

@@ -26,7 +26,7 @@ interface WorldGroup {
getObjectByName(name: string): unknown; getObjectByName(name: string): unknown;
} }
const TERRAIN_WIDTH = 360; const TERRAIN_WIDTH = 620;
const ROAD_CLEAR_HALF_WIDTH = 10.3; const ROAD_CLEAR_HALF_WIDTH = 10.3;
const RIDGE_START = 46; const RIDGE_START = 46;
const NOISE_PERIOD = 5_694; const NOISE_PERIOD = 5_694;
@@ -48,7 +48,7 @@ export function createTerrain(
uRouteProgress: { value: 0 }, uRouteProgress: { value: 0 },
uTerrainEnergy: { value: initialPalette.terrainEnergy }, uTerrainEnergy: { value: initialPalette.terrainEnergy },
uOctaves: { value: quality.terrainOctaves }, uOctaves: { value: quality.terrainOctaves },
uRelief: { value: 34 }, uRelief: { value: 49 },
uFar: { value: HORIZON_FOG_END }, uFar: { value: HORIZON_FOG_END },
uLowColor: { value: lowColor }, uLowColor: { value: lowColor },
uHighColor: { value: highColor }, uHighColor: { value: highColor },
@@ -134,8 +134,10 @@ export function createTerrain(
color = mix(color, mix(uFogColor, uHazeColor, 0.2), 1.0 - vDepthFade); color = mix(color, mix(uFogColor, uHazeColor, 0.2), 1.0 - vDepthFade);
float pulse = 0.72 + 0.28 * smoothstep(0.16, 0.55, vFlow); float pulse = 0.72 + 0.28 * smoothstep(0.16, 0.55, vFlow);
float routeCalm = mix(1.0, 0.9, smoothstep(0.82, 1.0, uRouteProgress)); float routeCalm = mix(1.0, 0.9, smoothstep(0.82, 1.0, uRouteProgress));
float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.32, 0.82, ridgeMix); float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.54, 0.88, ridgeMix);
gl_FragColor = vec4(color * mix(0.78, 1.55, ridgeMix), alpha); gl_FragColor = vec4(color * mix(1.15, 1.75, ridgeMix), alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
} }
`, `,
transparent: true, transparent: true,

317
src/styles/evolution.css Normal file
View File

@@ -0,0 +1,317 @@
/* Midnight grand tour. Surface radii: outer 16px, inner/controls 8px.
Existing content, navigation and audio controls retain their contracts. */
:root {
--canvas: #080d18;
--surface: rgb(12 20 32 / 96%);
--surface-strong: #0c1420;
--surface-soft: #132130;
--line: rgb(160 196 212 / 18%);
--line-strong: rgb(150 211 224 / 38%);
--text: #eff4f5;
--text-muted: #b3c2ce;
--accent: #9edce2;
--radius-surface: 16px;
--radius-control: 8px;
--panel-shadow: 0 24px 80px rgb(2 8 18 / 24%);
--site-navigation-block-size: 76px;
}
body { min-height: 100dvh; }
.site-navigation {
inset: 14px max(24px, calc((100vw - 1400px) / 2)) auto;
min-block-size: 52px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgb(10 18 30 / 94%);
box-shadow: 0 8px 32px rgb(2 8 18 / 12%);
}
.site-navigation ul { grid-auto-rows: 50px; padding-inline: 8px; }
.site-navigation a {
font-family: var(--sans);
font-size: 0.82rem;
font-weight: 520;
letter-spacing: 0;
border-block-end-width: 1px;
}
.site-navigation a[aria-current="page"] { background: rgb(158 220 226 / 5%); }
.hero-journey {
min-block-size: calc(100dvh - var(--site-navigation-block-size));
padding: clamp(38px, 7vh, 72px) max(5vw, calc((100vw - 1360px) / 2)) 150px;
position: relative;
align-content: start;
}
.hero-journey__content {
inline-size: min(620px, 47vw);
padding: 0;
gap: 22px;
border: 0;
border-radius: 0;
background: none;
box-shadow: none;
}
.hero-journey h1 {
font-size: clamp(76px, 7.5vw, 116px);
line-height: 0.96;
font-weight: 560;
letter-spacing: -0.065em;
max-inline-size: 6.3ch;
text-wrap: initial;
}
.hero-journey__role {
max-inline-size: 28ch;
font-size: clamp(21px, 1.8vw, 28px);
font-weight: 450;
letter-spacing: -0.025em;
line-height: 1.3;
}
.hero-journey__value { max-inline-size: 38ch; line-height: 1.55; }
.hero-journey__availability { max-inline-size: 42ch; font-size: 0.83rem; line-height: 1.5; }
.hero-journey__actions { gap: 26px; padding-block-start: 4px; }
.hero-journey__action,
.contact-link,
.project-link { border-radius: 8px; white-space: nowrap; font-family: var(--sans); letter-spacing: 0; font-size: 0.9rem; }
.hero-journey__action--primary,
.contact-link { padding: 0 23px; min-block-size: 49px; font-weight: 650; }
.hero-journey__action--primary:hover,
.contact-link:hover { color: #08151c; background: #c2edf0; }
.hero-journey__action:active,
.contact-link:active,
.project-link:active,
.drive-start:active,
.drive-control-button:active { transform: translateY(1px) scale(0.98); }
.portfolio-main { max-inline-size: 1920px; margin-inline: auto; }
.portfolio-section {
padding: clamp(28px, 4vw, 64px);
margin-block: clamp(140px, 23vh, 250px);
border-color: var(--line);
}
.portfolio-section > h2 {
font-size: clamp(42px, 4.7vw, 72px);
font-weight: 500;
letter-spacing: -0.055em;
margin-block-end: 36px;
}
.portfolio-section[data-layout="right-rail"] { inline-size: 46%; margin-inline: 49% 5%; }
.portfolio-section[data-layout="left-timeline"] { inline-size: 63%; margin-inline: 5% 32%; }
.portfolio-section[data-layout="full-diagram"],
.portfolio-section[data-layout="toolkit-field"] {
inline-size: 84%;
margin-inline: 8%;
padding: clamp(28px, 4vw, 64px);
border: 1px solid var(--line);
border-radius: var(--radius-surface);
background: var(--surface);
}
.portfolio-section[data-layout="project-stage"] { inline-size: 90%; margin-inline: 5%; }
.portfolio-section[data-layout="compact-rail"] { inline-size: 80%; margin-inline: 15% 5%; }
.portfolio-section[data-layout="destination"] { inline-size: min(680px, 53%); margin-inline: 5% auto; }
.portfolio-section[data-layout="project-stage"] > h2,
.portfolio-section[data-layout="project-stage"] > .section-eyebrow {
padding: 0;
border: 0;
background: transparent;
text-shadow: 0 2px 24px #080d18;
}
.section-eyebrow { font-weight: 500; font-size: 0.7rem; letter-spacing: 0.1em; }
.portfolio-section[data-layout="right-rail"] > p { font-size: clamp(19px, 1.5vw, 24px); line-height: 1.6; letter-spacing: -0.02em; }
.profile-facts { margin-block-start: 34px; gap: 0 20px; }
.profile-facts dt { font-family: var(--sans); text-transform: none; letter-spacing: 0; font-size: 0.8rem; }
.profile-facts dd { font-size: 0.95rem; }
.experience-timeline { padding-inline-start: 2rem !important; }
.experience-timeline::before { background: linear-gradient(var(--cyan), var(--line), transparent); }
.experience-timeline > li::before { inset-inline-start: -2.02rem; border-radius: 50%; transform: none; }
.experience-record { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.2fr); column-gap: 36px; }
.experience-record h3 { grid-column: 1; font-size: clamp(22px, 2.1vw, 32px); font-weight: 500; }
.experience-record__meta { grid-column: 1; }
.experience-record ul { grid-column: 2; grid-row: 1 / span 3; margin-block-start: 0; font-size: 0.95rem; }
.systems-operations { padding: 28px; border-color: var(--cyan); border-radius: 0 8px 8px 0; }
.systems-operations h3 { font-weight: 500; }
.infrastructure-case > h3,
.infrastructure-case > p,
.portfolio-section[data-layout="full-diagram"] > h2 { padding: 0; background: none; }
.infrastructure-case > h3 { font-size: clamp(25px, 2.5vw, 38px); font-weight: 480; }
.infrastructure-case > p { max-inline-size: 66ch; }
.portfolio-section .infrastructure-flow-figure { padding: 20px; background: #101e2d; border: 0; border-radius: 12px; margin-block: 36px; }
.infrastructure-node { border-inline-start-width: 1px; border-radius: 8px; min-block-size: 150px; background: #172b3b; box-shadow: 0 12px 28px rgb(4 12 24 / 22%); }
.infrastructure-node__sequence { font-size: 0.6rem; color: var(--text-muted); }
.infrastructure-node strong { font-weight: 550; font-size: clamp(16px, 1.4vw, 21px); letter-spacing: -0.02em; }
.infrastructure-node--operations { border-color: var(--line-strong); }
.operations-proof { padding: 32px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); gap: 24px 36px; }
.operations-proof > h4 { font-size: 1.5rem; font-weight: 520; letter-spacing: -0.03em; }
[data-testid="infrastructure-text-equivalent"] { margin-block: 30px; }
.project-stage__projects { gap: clamp(90px, 14vh, 160px); }
.project-case-study,
.project-case-study[data-project-priority="supporting"],
.project-case-study[data-project-id][data-media-layout] {
inline-size: 100%;
margin-inline: 0;
padding: clamp(28px, 4vw, 64px);
border: 1px solid var(--line);
background: var(--surface);
border-radius: 16px;
}
.project-case-study__header { max-inline-size: 810px; border: 0; padding-block-end: 0; gap: 20px; }
.project-case-study__header h3,
.project-case-study[data-project-priority="supporting"] .project-case-study__header h3 { font-size: clamp(32px, 4vw, 60px); font-weight: 480; letter-spacing: -0.045em; }
.project-case-study__header p { max-inline-size: 65ch; font-size: 1.05rem; line-height: 1.65; }
.project-media-gallery { margin-block: 40px; }
.portfolio-section .project-media { background: transparent; padding: 0; border: 0; border-radius: 0; }
.project-media img { border-radius: 10px; border-color: var(--line); }
.portfolio-section figcaption { margin-block-start: 15px; font-size: 0.84rem; line-height: 1.5; }
.project-media-gallery[data-media-layout="portrait-strip"] { gap: clamp(20px, 4vw, 60px); padding: 36px clamp(16px, 6vw, 88px); border-radius: 12px; background: #172532; }
.project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item + .project-media-gallery__item { margin-inline-start: 0; }
.project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item:nth-child(even) { margin-block-start: 42px; }
.project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item:nth-child(3n) { margin-block-start: 0; }
.project-media-gallery[data-media-layout="portrait-strip"] img { box-shadow: 0 18px 40px rgb(3 9 18 / 35%); border-radius: 18px; }
.project-media-gallery[data-media-layout="single"] { max-inline-size: 1080px; }
.project-evidence { grid-template-columns: 1fr 1.3fr; gap: 28px 46px; margin-block-start: 32px; }
.project-evidence > div { display: block; padding-block: 20px 0; border-block-start: 1px solid var(--line); border-block-end: 0; }
.project-evidence > div:last-child { grid-column: 1 / -1; }
.project-evidence dt { font-family: var(--sans); font-size: 0.9rem; text-transform: none; letter-spacing: 0; margin-block-end: 12px; }
.project-evidence dd { font-size: 0.98rem; line-height: 1.65; }
.project-evidence ul { columns: 2; column-gap: 48px; }
.project-evidence li { break-inside: avoid; }
.project-link { padding-inline: 20px; margin-block-start: 32px; min-block-size: 48px; }
.compact-projects { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 30px 42px; }
.project-card,
.project-card[data-maturity] { margin-inline: 0; padding: 24px 0 0; }
.project-card h3 { font-size: 1.5rem; font-weight: 530; letter-spacing: -0.03em; }
.toolkit-grid { grid-template-columns: 1fr; gap: 0; }
.toolkit-group { display: grid; grid-template-columns: minmax(150px, 0.75fr) minmax(0, 2fr); padding-block: 28px; gap: 28px; }
.toolkit-group h3 { font-weight: 500; font-size: clamp(22px, 2vw, 30px); }
.toolkit-items { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-block: 0; gap: 12px 24px; padding: 0 !important; }
.toolkit-item { grid-template-columns: 26px minmax(0, 1fr); gap: 12px; min-block-size: 56px; border: 0; font-size: 0.91rem; }
.toolkit-item .brand-icon { inline-size: 24px; block-size: 24px; color: var(--text); }
.contact-destination__copy { font-size: clamp(32px, 3vw, 46px); font-weight: 450; letter-spacing: -0.045em; max-inline-size: 22ch; }
.contact-social { border-block-start: 1px solid var(--line); padding-block-start: 24px !important; }
.drive-controls { inset-inline-start: 24px; inset-block-end: 22px; }
.drive-controls__dock { border-radius: 9px; padding: 4px; }
.drive-start { min-inline-size: 175px; border-radius: 6px; box-shadow: none; font-size: 0.64rem; letter-spacing: 0.06em; }
.drive-control-button { border-radius: 6px; }
.drive-controls__settings-trigger { border-radius: 8px; }
.drive-popover { border-radius: 10px; }
.journey-hud { inset-inline-end: 24px; inset-block-end: 22px; border-radius: 8px; padding: 14px 18px; }
@media (prefers-reduced-motion: no-preference) {
.hero-journey__content > * { animation: grand-tour-enter 900ms cubic-bezier(0.16, 1, 0.3, 1) both; }
.hero-journey__content > :nth-child(2) { animation-delay: 90ms; }
.hero-journey__content > :nth-child(3) { animation-delay: 180ms; }
.hero-journey__content > :nth-child(4) { animation-delay: 240ms; }
.hero-journey__content > :nth-child(5) { animation-delay: 300ms; }
.project-media img { transition: transform 650ms cubic-bezier(0.16, 1, 0.3, 1); }
.project-media:hover img { transform: translateY(-4px); }
@supports (animation-timeline: view()) {
.portfolio-section > h2, .project-case-study__header { animation: grand-tour-enter linear both; animation-timeline: view(); animation-range: entry 0% entry 95%; }
}
}
@keyframes grand-tour-enter { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: translateY(0); } }
@media (min-width: 768px) and (max-width: 1100px) {
.hero-journey__content { inline-size: 55%; }
.hero-journey h1 { font-size: clamp(76px, 9vw, 100px); }
.portfolio-section[data-layout="right-rail"] { inline-size: 65%; margin-inline: 30% 5%; }
.portfolio-section[data-layout="left-timeline"] { inline-size: 75%; margin-inline: 5% 20%; }
.experience-record { display: block; }
.experience-record ul { margin-block-start: 20px; }
.portfolio-section[data-layout="full-diagram"], .portfolio-section[data-layout="toolkit-field"] { inline-size: 90%; margin-inline: 5%; }
.infrastructure-flow { padding-inline: 0; }
.infrastructure-flow__route { gap: 36px 20px; }
.infrastructure-node { padding: 12px; }
.infrastructure-node__connector { inline-size: 20px; }
.infrastructure-boundary { font-size: 0.51rem; }
.toolkit-items { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.portfolio-section[data-layout="compact-rail"] { inline-size: 90%; margin-inline: 5%; }
}
@media (max-width: 767px) {
:root { --site-navigation-block-size: 70px; }
.site-navigation { inset: 10px 12px auto; }
.site-navigation summary { min-block-size: 48px; }
.site-navigation ul { padding: 8px; border-radius: 0 0 10px 10px; }
.hero-journey { min-block-size: calc(100dvh - 70px); padding: 32px 24px 260px; }
.hero-journey__content { inline-size: 100%; max-inline-size: 460px; gap: 16px; background: none; padding: 0; }
.hero-journey h1 { font-size: clamp(62px, 17vw, 94px); line-height: 0.98; }
.hero-journey__role { font-size: 20px; max-inline-size: 30ch; }
.hero-journey__value { font-size: 0.96rem; max-inline-size: 33ch; }
.hero-journey__availability { font-size: 0.78rem; }
.hero-journey__actions { gap: 20px; }
.hero-journey__action { font-size: 0.83rem; }
.hero-journey__action--primary { min-block-size: 46px; padding-inline: 18px; }
.portfolio-section, .portfolio-section[data-layout] { inline-size: calc(100% - 24px); margin: 110px 12px; padding: 28px 22px; background: var(--surface-strong); border-radius: 14px; }
.portfolio-section[data-layout="project-stage"] { padding: 0; background: transparent; }
.portfolio-section > h2 { font-size: 44px; margin-block-end: 26px; }
.portfolio-section[data-layout="project-stage"] > h2, .portfolio-section[data-layout="project-stage"] > .section-eyebrow { margin-inline: 12px; }
.portfolio-section[data-layout="right-rail"] > p { font-size: 20px; }
.experience-record { display: block; }
.experience-record ul { margin-block-start: 18px; }
.systems-operations { padding: 20px; }
.operations-proof { padding: 20px; }
.portfolio-section .infrastructure-flow-figure { padding: 15px; }
.infrastructure-node { min-block-size: 125px; }
.project-case-study, .project-case-study[data-project-priority="supporting"], .project-case-study[data-project-id][data-media-layout] { padding: 28px 22px; border-radius: 14px; }
.project-case-study__header h3, .project-case-study[data-project-priority="supporting"] .project-case-study__header h3 { font-size: 34px; }
.project-case-study__header p { font-size: 1rem; }
.project-media-gallery[data-media-layout="portrait-strip"] { grid-template-columns: minmax(0, 1fr); padding: 22px; gap: 32px; }
.project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item:nth-child(even) { margin-block-start: 0; }
.project-media-gallery[data-media-layout="portrait-strip"] img { max-inline-size: 290px; margin-inline: auto; }
.project-evidence { grid-template-columns: 1fr; gap: 12px; }
.project-evidence ul { columns: auto; }
.compact-projects { grid-template-columns: 1fr; }
.toolkit-group { grid-template-columns: 1fr; gap: 20px; }
.toolkit-items { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.toolkit-item { font-size: 0.82rem; gap: 8px; }
.drive-controls { inset-inline-start: 10px; inset-block-end: 10px; }
.drive-start { min-inline-size: 144px; font-size: 0.58rem; }
.journey-hud { inset-inline-end: 10px; inset-block-end: 10px; max-inline-size: 90px; padding: 14px 10px; }
.contact-destination__copy { font-size: 34px; }
}
@media (max-width: 360px) {
.hero-journey { padding-inline: 20px; }
.hero-journey h1 { font-size: 59px; }
.hero-journey__role { font-size: 18px; }
.drive-start { min-inline-size: 132px; padding-inline: 8px; }
.journey-hud { max-inline-size: 78px; font-size: 0.6rem; }
}
@media (max-height: 650px) and (min-width: 768px) {
.hero-journey { padding-block: 24px 90px; }
.hero-journey__content { gap: 12px; inline-size: 52%; }
.hero-journey h1 { font-size: 68px; max-inline-size: none; }
.hero-journey__role { font-size: 20px; }
.hero-journey__value { font-size: 0.92rem; }
}
@media print {
.hero-journey__content > *, .portfolio-section > h2, .project-case-study__header { animation: none !important; }
}
/* An open atmospheric scrim protects copy without restoring the old hero card. */
.hero-journey { isolation: isolate; }
.hero-journey::before {
content: '';
position: absolute;
z-index: -1;
inset: -76px 0 0;
pointer-events: none;
background: linear-gradient(90deg, rgb(8 13 24 / 98%), rgb(8 13 24 / 88%) 31%, rgb(8 13 24 / 25%) 51%, transparent 70%);
}
@media (min-width: 1100px) {
.project-case-study[data-media-layout="none"] {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
gap: 0 64px;
}
.project-case-study[data-media-layout="none"] .project-case-study__header { grid-column: 1; align-content: start; }
.project-case-study[data-media-layout="none"] .project-evidence { grid-column: 2; grid-row: 1 / span 2; margin-block-start: 0; grid-template-columns: 1fr; }
.project-case-study[data-media-layout="none"] .project-evidence ul { columns: auto; }
.project-case-study[data-media-layout="none"] .project-link { grid-column: 1; align-self: end; }
}
@media (max-width: 767px) {
.hero-journey::before { inset-block-start: -70px; background: linear-gradient(180deg, rgb(8 13 24 / 98%) 0%, rgb(8 13 24 / 92%) 43%, rgb(8 13 24 / 35%) 64%, transparent 85%); }
}
/* Dense approved copy uses a reading scale, with hierarchy carried by headings. */
.contact-destination__copy { font-size: clamp(26px, 2.25vw, 34px); line-height: 1.25; max-inline-size: 30ch; }
.portfolio-section .toolkit-item { margin-block-start: 0; }
.project-media--image > a { display: block; max-inline-size: none; cursor: zoom-in; border-radius: 10px; }
.project-media--image > a:focus-visible { outline-offset: 6px; }
.journey-hud__route { flex-shrink: 0; white-space: nowrap; }
.journey-hud__section { min-inline-size: 0; }
@media (min-width: 768px) and (max-aspect-ratio: 59/50) {
.hero-journey::before { background: linear-gradient(180deg, rgb(8 13 24 / 96%) 0%, rgb(8 13 24 / 90%) 55%, rgb(8 13 24 / 28%) 73%, transparent 90%); }
}
@media (max-width: 767px) {
.hero-journey::before { background: linear-gradient(180deg, rgb(8 13 24 / 98%) 0%, rgb(8 13 24 / 92%) 53%, rgb(8 13 24 / 48%) 66%, transparent 85%); }
}