fix: close final portfolio quality gaps
This commit is contained in:
@@ -1,21 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import IconGlyph, { type IconGlyphName } from './IconGlyph.vue';
|
||||
import type { AudioUiState } from '@/audio/types';
|
||||
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import IconGlyph, { type IconGlyphName } from "./IconGlyph.vue";
|
||||
import type { AudioUiState } from "@/audio/types";
|
||||
import type { MotionPolicy, QualityTier } from "@/journey/motion-policy";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
policy: MotionPolicy;
|
||||
maximumQuality: QualityTier;
|
||||
sceneStatus: string;
|
||||
audioState?: AudioUiState;
|
||||
audioMuted?: boolean;
|
||||
audioVolume?: number;
|
||||
}>(), {
|
||||
audioState: () => ({ kind: 'idle' }),
|
||||
audioMuted: false,
|
||||
audioVolume: 0.4,
|
||||
});
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
policy: MotionPolicy;
|
||||
maximumQuality: QualityTier;
|
||||
sceneStatus: string;
|
||||
audioState?: AudioUiState;
|
||||
audioMuted?: boolean;
|
||||
audioVolume?: number;
|
||||
}>(),
|
||||
{
|
||||
audioState: () => ({ kind: "idle" }),
|
||||
audioMuted: false,
|
||||
audioVolume: 0.4,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
pause: [];
|
||||
@@ -35,50 +45,80 @@ const settingsOpen = ref(false);
|
||||
const volumeOpen = ref(false);
|
||||
|
||||
const action = computed(() => {
|
||||
if (props.policy.reason === 'visitor-pause') {
|
||||
return { label: 'Resume motion', event: 'resume' as const, icon: 'play' as IconGlyphName };
|
||||
if (props.policy.reason === "visitor-pause") {
|
||||
return {
|
||||
label: "Resume motion",
|
||||
event: "resume" as const,
|
||||
icon: "play" as IconGlyphName,
|
||||
};
|
||||
}
|
||||
if (props.policy.reason === 'saved-static') {
|
||||
return { label: 'Enable motion', event: 'enable' as const, icon: 'play' as IconGlyphName };
|
||||
if (props.policy.reason === "saved-static") {
|
||||
return {
|
||||
label: "Enable motion",
|
||||
event: "enable" as const,
|
||||
icon: "play" as IconGlyphName,
|
||||
};
|
||||
}
|
||||
if (props.policy.reason === 'full') {
|
||||
return { label: 'Pause motion', event: 'pause' as const, icon: 'pause' as IconGlyphName };
|
||||
if (props.policy.reason === "full") {
|
||||
return {
|
||||
label: "Pause motion",
|
||||
event: "pause" as const,
|
||||
icon: "pause" as IconGlyphName,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const isAudioActive = computed(() => (
|
||||
props.audioState.kind === 'running' || props.audioState.kind === 'suspended'
|
||||
));
|
||||
const muteLabel = computed(() => props.audioMuted ? 'Unmute sound' : 'Mute sound');
|
||||
const muteIcon = computed<IconGlyphName>(() => props.audioMuted ? 'sound-muted' : 'sound');
|
||||
const isAudioActive = computed(
|
||||
() =>
|
||||
props.audioState.kind === "running" ||
|
||||
props.audioState.kind === "suspended",
|
||||
);
|
||||
const muteLabel = computed(() =>
|
||||
props.audioMuted ? "Unmute sound" : "Mute sound",
|
||||
);
|
||||
const muteIcon = computed<IconGlyphName>(() =>
|
||||
props.audioMuted ? "sound-muted" : "sound",
|
||||
);
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (props.policy.reason === 'system-reduce') return 'Motion: static — reduced motion requested';
|
||||
if (props.policy.reason === 'data-saver') return 'Motion: static — data saver is active';
|
||||
if (props.policy.reason === 'visitor-pause') return 'Motion: paused';
|
||||
if (props.policy.reason === 'saved-static') return 'Motion: static';
|
||||
if (props.sceneStatus === 'failed' || props.sceneStatus === 'static') return 'Visuals unavailable';
|
||||
if (props.sceneStatus === 'loading' || props.sceneStatus === 'idle') return 'Motion: preparing';
|
||||
if (props.policy.reason === "system-reduce")
|
||||
return "Motion: static; reduced motion requested";
|
||||
if (props.policy.reason === "data-saver")
|
||||
return "Motion: static; data saver is active";
|
||||
if (props.policy.reason === "visitor-pause") return "Motion: paused";
|
||||
if (props.policy.reason === "saved-static") return "Motion: static";
|
||||
if (props.sceneStatus === "failed" || props.sceneStatus === "static")
|
||||
return "Visuals unavailable";
|
||||
if (props.sceneStatus === "loading" || props.sceneStatus === "idle")
|
||||
return "Motion: preparing";
|
||||
return null;
|
||||
});
|
||||
|
||||
const runAction = (): void => {
|
||||
const event = action.value?.event;
|
||||
if (event === 'pause') emit('pause');
|
||||
else if (event === 'resume') emit('resume');
|
||||
else if (event === 'enable') emit('enable');
|
||||
if (event === "pause") emit("pause");
|
||||
else if (event === "resume") emit("resume");
|
||||
else if (event === "enable") emit("enable");
|
||||
};
|
||||
|
||||
const changeQuality = (event: Event): void => {
|
||||
const value = (event.currentTarget as HTMLSelectElement).value;
|
||||
if (value === 'high' || value === 'medium' || value === 'low' || value === 'static') {
|
||||
emit('qualityChange', value);
|
||||
if (
|
||||
value === "high" ||
|
||||
value === "medium" ||
|
||||
value === "low" ||
|
||||
value === "static"
|
||||
) {
|
||||
emit("qualityChange", value);
|
||||
}
|
||||
};
|
||||
|
||||
const changeVolume = (event: Event): void => {
|
||||
emit('audioVolumeChange', Number((event.currentTarget as HTMLInputElement).value));
|
||||
emit(
|
||||
"audioVolumeChange",
|
||||
Number((event.currentTarget as HTMLInputElement).value),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSettings = (): void => {
|
||||
@@ -92,7 +132,9 @@ const toggleVolume = (): void => {
|
||||
};
|
||||
|
||||
const closePanels = (restoreFocus = false): void => {
|
||||
const focusTarget = settingsOpen.value ? settingsButton.value : volumeButton.value;
|
||||
const focusTarget = settingsOpen.value
|
||||
? settingsButton.value
|
||||
: volumeButton.value;
|
||||
settingsOpen.value = false;
|
||||
volumeOpen.value = false;
|
||||
if (restoreFocus) void nextTick(() => focusTarget?.focus());
|
||||
@@ -104,7 +146,8 @@ const onDocumentPointerDown = (event: Event): void => {
|
||||
};
|
||||
|
||||
const onDocumentKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Escape' || (!settingsOpen.value && !volumeOpen.value)) return;
|
||||
if (event.key !== "Escape" || (!settingsOpen.value && !volumeOpen.value))
|
||||
return;
|
||||
event.preventDefault();
|
||||
closePanels(true);
|
||||
};
|
||||
@@ -115,13 +158,13 @@ watch(
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||
document.addEventListener('keydown', onDocumentKeyDown);
|
||||
document.addEventListener("pointerdown", onDocumentPointerDown);
|
||||
document.addEventListener("keydown", onDocumentKeyDown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||
document.removeEventListener('keydown', onDocumentKeyDown);
|
||||
document.removeEventListener("pointerdown", onDocumentPointerDown);
|
||||
document.removeEventListener("keydown", onDocumentKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -154,13 +197,24 @@ onBeforeUnmount(() => {
|
||||
<span>STARTING…</span>
|
||||
</button>
|
||||
|
||||
<p v-else-if="audioState.kind === 'failed'" class="drive-controls__audio-status" role="status">
|
||||
<p
|
||||
v-else-if="audioState.kind === 'failed'"
|
||||
class="drive-controls__audio-status"
|
||||
role="status"
|
||||
>
|
||||
Audio unavailable. Continue without sound.
|
||||
</p>
|
||||
|
||||
<div v-else-if="isAudioActive" class="drive-controls__dock" role="group" aria-label="Drive controls">
|
||||
<div
|
||||
v-else-if="isAudioActive"
|
||||
class="drive-controls__dock"
|
||||
role="group"
|
||||
aria-label="Drive controls"
|
||||
>
|
||||
<button
|
||||
v-if="audioState.kind === 'suspended' && audioState.resumeRequiresGesture"
|
||||
v-if="
|
||||
audioState.kind === 'suspended' && audioState.resumeRequiresGesture
|
||||
"
|
||||
class="drive-control-button"
|
||||
type="button"
|
||||
aria-label="Resume sound"
|
||||
@@ -208,6 +262,18 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="action && !isAudioActive && action.event !== 'pause'"
|
||||
class="drive-control-button"
|
||||
type="button"
|
||||
:aria-label="action.label"
|
||||
:title="action.label"
|
||||
:data-tooltip="action.label"
|
||||
@click="runAction"
|
||||
>
|
||||
<IconGlyph :name="action.icon" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="audioState.kind !== 'starting'"
|
||||
ref="settingsButton"
|
||||
@@ -223,6 +289,14 @@ onBeforeUnmount(() => {
|
||||
<IconGlyph name="settings" />
|
||||
</button>
|
||||
|
||||
<p
|
||||
v-if="statusText"
|
||||
class="drive-controls__status drive-controls__announcer visually-hidden"
|
||||
role="status"
|
||||
>
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="volumeOpen && isAudioActive"
|
||||
id="drive-volume-panel"
|
||||
@@ -240,7 +314,7 @@ onBeforeUnmount(() => {
|
||||
step="0.05"
|
||||
:value="audioVolume"
|
||||
@input="changeVolume"
|
||||
>
|
||||
/>
|
||||
<output for="drive-volume">{{ Math.round(audioVolume * 100) }}%</output>
|
||||
</div>
|
||||
|
||||
@@ -253,13 +327,18 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<strong>DRIVE SETTINGS</strong>
|
||||
<label for="drive-quality">QUALITY</label>
|
||||
<select id="drive-quality" :value="maximumQuality" aria-label="Quality control" @change="changeQuality">
|
||||
<select
|
||||
id="drive-quality"
|
||||
:value="maximumQuality"
|
||||
aria-label="Quality control"
|
||||
@change="changeQuality"
|
||||
>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="static">Static</option>
|
||||
</select>
|
||||
<p v-if="statusText" class="drive-controls__status" role="status">{{ statusText }}</p>
|
||||
<p v-if="statusText" class="drive-controls__status">{{ statusText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { InfrastructureCaseStudy } from '@/content/types';
|
||||
import type { InfrastructureCaseStudy } from "@/content/types";
|
||||
|
||||
defineProps<{ infrastructure: Readonly<InfrastructureCaseStudy> }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="infrastructure-case">
|
||||
<h3 :id="`${infrastructure.id}-diagram-title`">{{ infrastructure.title }}</h3>
|
||||
<p :id="`${infrastructure.id}-diagram-description`">{{ infrastructure.summary }}</p>
|
||||
<h3 :id="`${infrastructure.id}-diagram-title`">
|
||||
{{ infrastructure.title }}
|
||||
</h3>
|
||||
<p :id="`${infrastructure.id}-diagram-description`">
|
||||
{{ infrastructure.summary }}
|
||||
</p>
|
||||
<figure class="infrastructure-flow-figure">
|
||||
<div
|
||||
class="infrastructure-flow"
|
||||
@@ -17,14 +21,22 @@ defineProps<{ infrastructure: Readonly<InfrastructureCaseStudy> }>();
|
||||
:aria-describedby="`${infrastructure.id}-diagram-description`"
|
||||
>
|
||||
<div class="infrastructure-flow__route" aria-hidden="true">
|
||||
<div class="infrastructure-node infrastructure-node--visitor" data-infrastructure-node="visitor">
|
||||
<div
|
||||
class="infrastructure-node infrastructure-node--visitor"
|
||||
data-infrastructure-node="visitor"
|
||||
>
|
||||
<span class="infrastructure-node__sequence">Request</span>
|
||||
<strong>Visitor</strong>
|
||||
<span class="infrastructure-node__detail">Inbound traffic</span>
|
||||
</div>
|
||||
|
||||
<div class="infrastructure-node infrastructure-node--ingress" data-infrastructure-node="nginx-tls">
|
||||
<span class="infrastructure-boundary" data-trust-boundary>Public edge</span>
|
||||
<div
|
||||
class="infrastructure-node infrastructure-node--ingress"
|
||||
data-infrastructure-node="nginx-tls"
|
||||
>
|
||||
<span class="infrastructure-boundary" data-trust-boundary
|
||||
>Public edge</span
|
||||
>
|
||||
<span class="infrastructure-node__connector"></span>
|
||||
<span class="infrastructure-node__sequence">01 / edge</span>
|
||||
<strong>Nginx and TLS</strong>
|
||||
@@ -32,35 +44,78 @@ defineProps<{ infrastructure: Readonly<InfrastructureCaseStudy> }>();
|
||||
<span class="infrastructure-node__guard">fail2ban guard</span>
|
||||
</div>
|
||||
|
||||
<div class="infrastructure-node infrastructure-node--access" data-infrastructure-node="authentication">
|
||||
<span class="infrastructure-boundary" data-trust-boundary>Access boundary</span>
|
||||
<div
|
||||
class="infrastructure-node infrastructure-node--access"
|
||||
data-infrastructure-node="authentication"
|
||||
>
|
||||
<span class="infrastructure-boundary" data-trust-boundary
|
||||
>Access boundary</span
|
||||
>
|
||||
<span class="infrastructure-node__connector"></span>
|
||||
<span class="infrastructure-node__sequence">02 / verify</span>
|
||||
<strong>Authentication</strong>
|
||||
<span class="infrastructure-node__detail">Protected access</span>
|
||||
</div>
|
||||
|
||||
<div class="infrastructure-node infrastructure-node--services" data-infrastructure-node="containerized-services">
|
||||
<span class="infrastructure-boundary" data-trust-boundary>Service boundary</span>
|
||||
<div
|
||||
class="infrastructure-node infrastructure-node--services"
|
||||
data-infrastructure-node="containerized-services"
|
||||
>
|
||||
<span class="infrastructure-boundary" data-trust-boundary
|
||||
>Service boundary</span
|
||||
>
|
||||
<span class="infrastructure-node__connector"></span>
|
||||
<span class="infrastructure-node__sequence">03 / run</span>
|
||||
<strong>Containerized services</strong>
|
||||
<span class="infrastructure-node__detail">Managed workloads</span>
|
||||
</div>
|
||||
|
||||
<div class="infrastructure-node infrastructure-node--operations" data-infrastructure-node="operations">
|
||||
<span class="infrastructure-node__connector infrastructure-node__connector--vertical"></span>
|
||||
<div
|
||||
class="infrastructure-node infrastructure-node--operations"
|
||||
data-infrastructure-node="operations"
|
||||
>
|
||||
<span
|
||||
class="infrastructure-node__connector infrastructure-node__connector--vertical"
|
||||
></span>
|
||||
<span class="infrastructure-node__sequence">04 / sustain</span>
|
||||
<strong>Operations</strong>
|
||||
<span class="infrastructure-node__detail">Lifecycle + maintenance</span>
|
||||
<span class="infrastructure-node__detail"
|
||||
>Lifecycle + maintenance</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>{{ infrastructure.title }}</figcaption>
|
||||
</figure>
|
||||
<dl
|
||||
class="infrastructure-legend"
|
||||
aria-label="Infrastructure diagram legend"
|
||||
>
|
||||
<div>
|
||||
<dt>
|
||||
<span
|
||||
class="infrastructure-legend__boundary"
|
||||
aria-hidden="true"
|
||||
/>Trust boundary
|
||||
</dt>
|
||||
<dd>Where trust or access rules change</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
<span class="infrastructure-legend__flow" aria-hidden="true" />Request
|
||||
flow
|
||||
</dt>
|
||||
<dd>The direction traffic moves through the system</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<ul data-testid="infrastructure-text-equivalent">
|
||||
<li>
|
||||
<strong>Legend</strong>: Boundary labels mark changes in trust or access
|
||||
rules. Connectors show request flow direction.
|
||||
</li>
|
||||
<li v-for="band in infrastructure.bands" :key="band.id">
|
||||
<strong>{{ band.title }}</strong>: {{ band.summary }}
|
||||
<strong>{{ band.title }}</strong
|
||||
>: {{ band.summary }}
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { SectionId } from "@/content/types";
|
||||
import type { JourneySource } from "./types";
|
||||
|
||||
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 interface NavigationState {
|
||||
epoch: number;
|
||||
@@ -80,7 +81,7 @@ export function validateSectionAnchor(
|
||||
const target = document.getElementById(id);
|
||||
if (!(target instanceof HTMLElement) || target.id !== id) return null;
|
||||
const heading = target.querySelector<HTMLElement>(
|
||||
':scope > h2[tabindex="-1"]',
|
||||
'h1[tabindex="-1"], h2[tabindex="-1"]',
|
||||
);
|
||||
return heading === null ? null : { id, target };
|
||||
}
|
||||
@@ -94,7 +95,7 @@ const browserRuntime = (): NavigationRuntime => ({
|
||||
target.scrollIntoView({ behavior, block: "start" }),
|
||||
focusHeading: (target) => {
|
||||
target
|
||||
.querySelector<HTMLElement>(':scope > h2[tabindex="-1"]')
|
||||
.querySelector<HTMLElement>('h1[tabindex="-1"], h2[tabindex="-1"]')
|
||||
?.focus({ preventScroll: true });
|
||||
},
|
||||
getScrollY: () => window.scrollY,
|
||||
@@ -165,8 +166,13 @@ export function createNavigationController(
|
||||
source,
|
||||
destinationId,
|
||||
};
|
||||
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.noteNavigationEpoch(currentState.epoch);
|
||||
if (TEST_BUILD && source === 'explicit-navigation' && (destinationId === 'toolkit' || destinationId === 'contact')) {
|
||||
if (TEST_BUILD)
|
||||
window.__PORTFOLIO_PERFORMANCE__?.noteNavigationEpoch(currentState.epoch);
|
||||
if (
|
||||
TEST_BUILD &&
|
||||
source === "explicit-navigation" &&
|
||||
(destinationId === "toolkit" || destinationId === "contact")
|
||||
) {
|
||||
window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation(destinationId);
|
||||
}
|
||||
emit();
|
||||
@@ -191,7 +197,11 @@ export function createNavigationController(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (TEST_BUILD && window.__PORTFOLIO_PERFORMANCE__?.holdNavigationSettlement(epoch)) return;
|
||||
if (
|
||||
TEST_BUILD &&
|
||||
window.__PORTFOLIO_PERFORMANCE__?.holdNavigationSettlement(epoch)
|
||||
)
|
||||
return;
|
||||
cancelSettle();
|
||||
frameId = runtime.requestFrame(() => {
|
||||
frameId = undefined;
|
||||
@@ -210,7 +220,8 @@ export function createNavigationController(
|
||||
id: SectionId,
|
||||
target: HTMLElement,
|
||||
): void => {
|
||||
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.registerNavigationSettlement(epoch);
|
||||
if (TEST_BUILD)
|
||||
window.__PORTFOLIO_PERFORMANCE__?.registerNavigationSettlement(epoch);
|
||||
settleTimerId = runtime.setTimer(() => {
|
||||
settleTimerId = undefined;
|
||||
if (
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import * as THREE from 'three';
|
||||
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
|
||||
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
|
||||
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import * as THREE from "three";
|
||||
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
|
||||
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
|
||||
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
|
||||
import type {
|
||||
GpuRenderTargetAllocation,
|
||||
GpuRenderTargetReporter,
|
||||
SceneResourceBag,
|
||||
} from "@/scene/resources";
|
||||
|
||||
export interface PostProcessor {
|
||||
export interface PostProcessor extends GpuRenderTargetReporter {
|
||||
resize(width: number, height: number, pixelRatio: number): void;
|
||||
render(scene: unknown, camera: unknown): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface RenderTargetLike {
|
||||
width: number;
|
||||
height: number;
|
||||
samples?: number;
|
||||
depthBuffer?: boolean;
|
||||
}
|
||||
|
||||
interface ComposerLike {
|
||||
renderTarget1?: RenderTargetLike;
|
||||
renderTarget2?: RenderTargetLike;
|
||||
addPass(pass: unknown): void;
|
||||
setPixelRatio(pixelRatio: number): void;
|
||||
setSize(width: number, height: number): void;
|
||||
@@ -25,6 +38,9 @@ interface RenderPassLike {
|
||||
}
|
||||
|
||||
interface BloomPassLike {
|
||||
renderTargetBright?: RenderTargetLike;
|
||||
renderTargetsHorizontal?: readonly RenderTargetLike[];
|
||||
renderTargetsVertical?: readonly RenderTargetLike[];
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -37,12 +53,8 @@ export interface PostProcessingFactories {
|
||||
const productionFactories: PostProcessingFactories = {
|
||||
createComposer: (renderer) => new EffectComposer(renderer),
|
||||
createRenderPass: (scene, camera) => new RenderPass(scene, camera),
|
||||
createBloomPass: () => new UnrealBloomPass(
|
||||
new THREE.Vector2(1, 1),
|
||||
0.46,
|
||||
0.32,
|
||||
0.84,
|
||||
),
|
||||
createBloomPass: () =>
|
||||
new UnrealBloomPass(new THREE.Vector2(1, 1), 0.46, 0.32, 0.84),
|
||||
};
|
||||
|
||||
export function createPostProcessor(
|
||||
@@ -51,13 +63,19 @@ export function createPostProcessor(
|
||||
factories: PostProcessingFactories = productionFactories,
|
||||
): PostProcessor {
|
||||
const composer = factories.createComposer(renderer);
|
||||
const renderPass = factories.createRenderPass(new THREE.Scene(), new THREE.Camera());
|
||||
const renderPass = factories.createRenderPass(
|
||||
new THREE.Scene(),
|
||||
new THREE.Camera(),
|
||||
);
|
||||
const bloomPass = factories.createBloomPass();
|
||||
composer.addPass(renderPass);
|
||||
composer.addPass(bloomPass);
|
||||
let disposed = false;
|
||||
|
||||
const processor: PostProcessor = {
|
||||
gpuRenderTargets() {
|
||||
return collectRenderTargetAllocations(composer, bloomPass);
|
||||
},
|
||||
resize(width, height, pixelRatio) {
|
||||
if (disposed) return;
|
||||
composer.setPixelRatio(clamp(pixelRatio, 0.5, 2));
|
||||
@@ -80,7 +98,41 @@ export function createPostProcessor(
|
||||
composer.dispose();
|
||||
},
|
||||
};
|
||||
return resources.own('pass', processor);
|
||||
return resources.own("pass", processor);
|
||||
}
|
||||
|
||||
function collectRenderTargetAllocations(
|
||||
composer: ComposerLike,
|
||||
bloomPass: BloomPassLike,
|
||||
): readonly GpuRenderTargetAllocation[] {
|
||||
const candidates: ReadonlyArray<
|
||||
readonly [string, RenderTargetLike | undefined]
|
||||
> = [
|
||||
["composer-target-1", composer.renderTarget1],
|
||||
["composer-target-2", composer.renderTarget2],
|
||||
["bloom-bright", bloomPass.renderTargetBright],
|
||||
...(bloomPass.renderTargetsHorizontal ?? []).map(
|
||||
(target, index) => [`bloom-horizontal-${index}`, target] as const,
|
||||
),
|
||||
...(bloomPass.renderTargetsVertical ?? []).map(
|
||||
(target, index) => [`bloom-vertical-${index}`, target] as const,
|
||||
),
|
||||
];
|
||||
const seen = new Set<RenderTargetLike>();
|
||||
return candidates.flatMap(([id, target]) => {
|
||||
if (target === undefined || seen.has(target)) return [];
|
||||
seen.add(target);
|
||||
return [
|
||||
{
|
||||
id,
|
||||
width: Math.max(1, Math.floor(finiteOr(target.width, 1))),
|
||||
height: Math.max(1, Math.floor(finiteOr(target.height, 1))),
|
||||
bytesPerPixel: 4,
|
||||
attachments: target.depthBuffer === false ? 1 : 2,
|
||||
samples: Math.max(1, Math.floor(finiteOr(target.samples ?? 1, 1))),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function finiteOr(value: number, fallback: number): number {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export type SceneResourceKind =
|
||||
| 'geometry'
|
||||
| 'material'
|
||||
| 'texture'
|
||||
| 'target'
|
||||
| 'pass'
|
||||
| 'renderer'
|
||||
| 'other';
|
||||
| "geometry"
|
||||
| "material"
|
||||
| "texture"
|
||||
| "target"
|
||||
| "pass"
|
||||
| "renderer"
|
||||
| "other";
|
||||
|
||||
export interface DisposableResource {
|
||||
dispose(): void;
|
||||
@@ -42,15 +42,25 @@ export interface GpuRenderTargetAllocation {
|
||||
samples: number;
|
||||
}
|
||||
|
||||
export interface GpuRenderTargetReporter {
|
||||
gpuRenderTargets(): readonly GpuRenderTargetAllocation[];
|
||||
}
|
||||
|
||||
export interface SceneGpuBudgetSnapshot {
|
||||
tier: 'low' | 'high';
|
||||
tier: "low" | "high";
|
||||
textures: readonly GpuTextureAllocation[];
|
||||
geometryBytes: number;
|
||||
renderTargets: readonly GpuRenderTargetAllocation[];
|
||||
}
|
||||
|
||||
const resourceKinds: readonly SceneResourceKind[] = [
|
||||
'geometry', 'material', 'texture', 'target', 'pass', 'renderer', 'other',
|
||||
"geometry",
|
||||
"material",
|
||||
"texture",
|
||||
"target",
|
||||
"pass",
|
||||
"renderer",
|
||||
"other",
|
||||
];
|
||||
|
||||
export class SceneResourceBag {
|
||||
@@ -141,7 +151,9 @@ export class SceneResourceBag {
|
||||
}
|
||||
|
||||
snapshot(): SceneResourceSnapshot {
|
||||
const owned = Object.fromEntries(resourceKinds.map((kind) => [kind, 0])) as Record<SceneResourceKind, number>;
|
||||
const owned = Object.fromEntries(
|
||||
resourceKinds.map((kind) => [kind, 0]),
|
||||
) as Record<SceneResourceKind, number>;
|
||||
for (const kind of this.resources.values()) owned[kind] += 1;
|
||||
return {
|
||||
disposed: this.disposed,
|
||||
@@ -154,23 +166,38 @@ export class SceneResourceBag {
|
||||
}
|
||||
|
||||
gpuBudgetSnapshot(input: {
|
||||
tier: 'low' | 'high';
|
||||
defaultFramebuffer: Omit<GpuRenderTargetAllocation, 'id'>;
|
||||
tier: "low" | "high";
|
||||
defaultFramebuffer: Omit<GpuRenderTargetAllocation, "id">;
|
||||
}): SceneGpuBudgetSnapshot {
|
||||
let geometryBytes = 0;
|
||||
const seenBuffers = new Set<ArrayBufferLike>();
|
||||
const textures: GpuTextureAllocation[] = [];
|
||||
const renderTargets: GpuRenderTargetAllocation[] = [{ id: 'default-framebuffer', ...input.defaultFramebuffer }];
|
||||
const renderTargets: GpuRenderTargetAllocation[] = [
|
||||
{ id: "default-framebuffer", ...input.defaultFramebuffer },
|
||||
];
|
||||
let textureIndex = 0;
|
||||
let targetIndex = 0;
|
||||
for (const [resource, kind] of this.resources) {
|
||||
if (kind === 'geometry') {
|
||||
const reporter = resource as DisposableResource &
|
||||
Partial<GpuRenderTargetReporter>;
|
||||
if (typeof reporter.gpuRenderTargets === "function") {
|
||||
for (const allocation of reporter.gpuRenderTargets()) {
|
||||
validateRenderTargetAllocation(allocation);
|
||||
renderTargets.push({ ...allocation });
|
||||
}
|
||||
}
|
||||
if (kind === "geometry") {
|
||||
const geometry = resource as DisposableResource & {
|
||||
attributes?: Record<string, { array?: ArrayBufferView; data?: { array?: ArrayBufferView } }>;
|
||||
attributes?: Record<
|
||||
string,
|
||||
{ array?: ArrayBufferView; data?: { array?: ArrayBufferView } }
|
||||
>;
|
||||
index?: { array?: ArrayBufferView } | null;
|
||||
};
|
||||
const arrays = [
|
||||
...Object.values(geometry.attributes ?? {}).map((attribute) => attribute.array ?? attribute.data?.array),
|
||||
...Object.values(geometry.attributes ?? {}).map(
|
||||
(attribute) => attribute.array ?? attribute.data?.array,
|
||||
),
|
||||
geometry.index?.array,
|
||||
].filter((array): array is ArrayBufferView => array !== undefined);
|
||||
for (const array of arrays) {
|
||||
@@ -179,28 +206,58 @@ export class SceneResourceBag {
|
||||
geometryBytes += array.byteLength;
|
||||
}
|
||||
}
|
||||
if (kind === 'texture') {
|
||||
if (kind === "texture") {
|
||||
const texture = resource as DisposableResource & {
|
||||
image?: { width?: number; height?: number; data?: ArrayBufferView };
|
||||
generateMipmaps?: boolean;
|
||||
};
|
||||
const width = Number(texture.image?.width);
|
||||
const height = Number(texture.image?.height);
|
||||
if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) {
|
||||
throw new Error('Owned production texture is missing measurable dimensions.');
|
||||
if (
|
||||
!Number.isInteger(width) ||
|
||||
width <= 0 ||
|
||||
!Number.isInteger(height) ||
|
||||
height <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
"Owned production texture is missing measurable dimensions.",
|
||||
);
|
||||
}
|
||||
const dataBytes = texture.image?.data?.byteLength;
|
||||
const bytesPerPixel = dataBytes === undefined ? 4 : dataBytes / (width * height);
|
||||
if (!Number.isInteger(bytesPerPixel) || bytesPerPixel <= 0) throw new Error('Owned production texture has an unsupported pixel layout.');
|
||||
textures.push({ id: `owned-texture-${textureIndex}`, width, height, bytesPerPixel, layers: 1, mipmapped: texture.generateMipmaps !== false });
|
||||
const bytesPerPixel =
|
||||
dataBytes === undefined ? 4 : dataBytes / (width * height);
|
||||
if (!Number.isInteger(bytesPerPixel) || bytesPerPixel <= 0)
|
||||
throw new Error(
|
||||
"Owned production texture has an unsupported pixel layout.",
|
||||
);
|
||||
textures.push({
|
||||
id: `owned-texture-${textureIndex}`,
|
||||
width,
|
||||
height,
|
||||
bytesPerPixel,
|
||||
layers: 1,
|
||||
mipmapped: texture.generateMipmaps !== false,
|
||||
});
|
||||
textureIndex += 1;
|
||||
}
|
||||
if (kind === 'target') {
|
||||
const target = resource as DisposableResource & { width?: number; height?: number; samples?: number; depthBuffer?: boolean };
|
||||
if (kind === "target") {
|
||||
const target = resource as DisposableResource & {
|
||||
width?: number;
|
||||
height?: number;
|
||||
samples?: number;
|
||||
depthBuffer?: boolean;
|
||||
};
|
||||
const width = Number(target.width);
|
||||
const height = Number(target.height);
|
||||
if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) {
|
||||
throw new Error('Owned production render target is missing measurable dimensions.');
|
||||
if (
|
||||
!Number.isInteger(width) ||
|
||||
width <= 0 ||
|
||||
!Number.isInteger(height) ||
|
||||
height <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
"Owned production render target is missing measurable dimensions.",
|
||||
);
|
||||
}
|
||||
renderTargets.push({
|
||||
id: `owned-render-target-${targetIndex}`,
|
||||
@@ -216,3 +273,25 @@ export class SceneResourceBag {
|
||||
return { tier: input.tier, textures, geometryBytes, renderTargets };
|
||||
}
|
||||
}
|
||||
|
||||
function validateRenderTargetAllocation(
|
||||
allocation: GpuRenderTargetAllocation,
|
||||
): void {
|
||||
if (
|
||||
allocation.id.length === 0 ||
|
||||
!Number.isInteger(allocation.width) ||
|
||||
allocation.width <= 0 ||
|
||||
!Number.isInteger(allocation.height) ||
|
||||
allocation.height <= 0 ||
|
||||
!Number.isInteger(allocation.bytesPerPixel) ||
|
||||
allocation.bytesPerPixel <= 0 ||
|
||||
!Number.isInteger(allocation.attachments) ||
|
||||
allocation.attachments <= 0 ||
|
||||
!Number.isInteger(allocation.samples) ||
|
||||
allocation.samples <= 0
|
||||
) {
|
||||
throw new Error(
|
||||
"Owned post-processing render target has an invalid allocation description.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
.hero-journey__action::after {
|
||||
content: '→';
|
||||
content: "→";
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
inset: 0 auto 0 0.23rem;
|
||||
inline-size: 1px;
|
||||
background: var(--line-strong);
|
||||
content: '';
|
||||
content: "";
|
||||
}
|
||||
|
||||
.experience-timeline > li {
|
||||
@@ -136,7 +136,7 @@
|
||||
block-size: 0.5rem;
|
||||
border: 1px solid var(--cyan);
|
||||
background: var(--canvas);
|
||||
content: '';
|
||||
content: "";
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@
|
||||
inline-size: 0.65rem;
|
||||
margin-inline-end: 0.4rem;
|
||||
border-block-start: 1px dashed var(--line-strong);
|
||||
content: '';
|
||||
content: "";
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
block-size: 0.36rem;
|
||||
border-block-start: 1px solid var(--cyan);
|
||||
border-inline-end: 1px solid var(--cyan);
|
||||
content: '';
|
||||
content: "";
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
@@ -293,7 +293,63 @@
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
|
||||
[data-testid='infrastructure-text-equivalent'] {
|
||||
.infrastructure-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.75rem;
|
||||
margin: var(--space-2) 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.infrastructure-legend > div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.infrastructure-legend dt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
color: var(--text);
|
||||
font: 600 0.68rem/1.2 var(--mono);
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.infrastructure-legend dd {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.infrastructure-legend__boundary,
|
||||
.infrastructure-legend__flow {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
inline-size: 1.65rem;
|
||||
block-size: 0.5rem;
|
||||
}
|
||||
|
||||
.infrastructure-legend__boundary {
|
||||
border-block-start: 1px dashed var(--line-strong);
|
||||
}
|
||||
|
||||
.infrastructure-legend__flow {
|
||||
border-block-start: 1px solid var(--cyan);
|
||||
}
|
||||
|
||||
.infrastructure-legend__flow::after {
|
||||
position: absolute;
|
||||
inset: -0.2rem 0 auto auto;
|
||||
inline-size: 0.36rem;
|
||||
block-size: 0.36rem;
|
||||
border-block-start: 1px solid var(--cyan);
|
||||
border-inline-end: 1px solid var(--cyan);
|
||||
content: "";
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
[data-testid="infrastructure-text-equivalent"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
@@ -301,13 +357,13 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
[data-testid='infrastructure-text-equivalent'] li {
|
||||
[data-testid="infrastructure-text-equivalent"] li {
|
||||
margin: 0;
|
||||
padding: var(--space-2);
|
||||
border-block-start: 1px solid var(--line);
|
||||
}
|
||||
|
||||
[data-testid='infrastructure-text-equivalent'] strong {
|
||||
[data-testid="infrastructure-text-equivalent"] strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -327,12 +383,12 @@
|
||||
box-shadow: var(--panel-shadow);
|
||||
}
|
||||
|
||||
.project-case-study[data-project-id='footy-tracker'],
|
||||
.project-case-study[data-project-id='public-build-two'] {
|
||||
.project-case-study[data-project-id="footy-tracker"],
|
||||
.project-case-study[data-project-id="public-build-two"] {
|
||||
margin-inline-start: 54%;
|
||||
}
|
||||
|
||||
.project-case-study[data-project-priority='supporting'] {
|
||||
.project-case-study[data-project-priority="supporting"] {
|
||||
inline-size: min(38rem, 62%);
|
||||
margin-inline: auto;
|
||||
background: var(--surface-soft);
|
||||
@@ -349,7 +405,9 @@
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
|
||||
.project-case-study[data-project-priority='supporting'] .project-case-study__header h3 {
|
||||
.project-case-study[data-project-priority="supporting"]
|
||||
.project-case-study__header
|
||||
h3 {
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
|
||||
@@ -395,12 +453,12 @@
|
||||
border-block-start: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.project-card[data-maturity='prototype'],
|
||||
.project-card[data-maturity='active'] {
|
||||
.project-card[data-maturity="prototype"],
|
||||
.project-card[data-maturity="active"] {
|
||||
margin-inline-end: 8%;
|
||||
}
|
||||
|
||||
.project-card[data-maturity='learning'] {
|
||||
.project-card[data-maturity="learning"] {
|
||||
margin-inline-start: 12%;
|
||||
}
|
||||
|
||||
@@ -576,8 +634,8 @@
|
||||
inline-size: 62%;
|
||||
}
|
||||
|
||||
.project-case-study[data-project-id='footy-tracker'],
|
||||
.project-case-study[data-project-id='public-build-two'] {
|
||||
.project-case-study[data-project-id="footy-tracker"],
|
||||
.project-case-study[data-project-id="public-build-two"] {
|
||||
margin-inline-start: 38%;
|
||||
}
|
||||
|
||||
@@ -633,7 +691,7 @@
|
||||
|
||||
.profile-facts,
|
||||
.project-evidence > div,
|
||||
[data-testid='infrastructure-text-equivalent'],
|
||||
[data-testid="infrastructure-text-equivalent"],
|
||||
.toolkit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,10 @@
|
||||
font: 520 0.78rem/1 var(--mono);
|
||||
letter-spacing: 0.06em;
|
||||
text-decoration: none;
|
||||
transition: color var(--transition), background-color var(--transition), border-color var(--transition);
|
||||
transition:
|
||||
color var(--transition),
|
||||
background-color var(--transition),
|
||||
border-color var(--transition);
|
||||
}
|
||||
|
||||
.site-navigation a:hover,
|
||||
@@ -58,7 +61,7 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.site-navigation a[aria-current='page'] {
|
||||
.site-navigation a[aria-current="page"] {
|
||||
border-block-end-color: var(--cyan);
|
||||
color: var(--cyan);
|
||||
}
|
||||
@@ -97,29 +100,31 @@
|
||||
min-width: 0;
|
||||
margin-block: clamp(6rem, 16vh, 11rem);
|
||||
padding: clamp(1.5rem, 2.6vw, 2.7rem);
|
||||
scroll-margin-block-start: calc(var(--site-navigation-block-size) + var(--space-3));
|
||||
scroll-margin-block-start: calc(
|
||||
var(--site-navigation-block-size) + var(--space-3)
|
||||
);
|
||||
border: var(--panel-border);
|
||||
border-radius: var(--radius-surface);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--panel-shadow);
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='right-rail'] {
|
||||
.portfolio-section[data-layout="right-rail"] {
|
||||
inline-size: 42%;
|
||||
margin-inline: 54% 4%;
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='left-timeline'] {
|
||||
.portfolio-section[data-layout="left-timeline"] {
|
||||
inline-size: 46%;
|
||||
margin-inline: 4% 50%;
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='full-diagram'] {
|
||||
.portfolio-section[data-layout="full-diagram"] {
|
||||
inline-size: 78%;
|
||||
margin-inline: 11%;
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='project-stage'] {
|
||||
.portfolio-section[data-layout="project-stage"] {
|
||||
inline-size: 92%;
|
||||
margin-inline: 4%;
|
||||
padding: 0;
|
||||
@@ -128,25 +133,25 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='project-stage'] > h2,
|
||||
.portfolio-section[data-layout='project-stage'] > .section-eyebrow {
|
||||
.portfolio-section[data-layout="project-stage"] > h2,
|
||||
.portfolio-section[data-layout="project-stage"] > .section-eyebrow {
|
||||
inline-size: fit-content;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border-inline-start: 2px solid var(--cyan);
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='compact-rail'] {
|
||||
.portfolio-section[data-layout="compact-rail"] {
|
||||
inline-size: 42%;
|
||||
margin-inline: 54% 4%;
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='toolkit-field'] {
|
||||
.portfolio-section[data-layout="toolkit-field"] {
|
||||
inline-size: 78%;
|
||||
margin-inline: 11%;
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='destination'] {
|
||||
.portfolio-section[data-layout="destination"] {
|
||||
inline-size: min(54rem, 62%);
|
||||
margin-inline: auto;
|
||||
}
|
||||
@@ -164,33 +169,33 @@
|
||||
inline-size: min(34rem, 62vw);
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='right-rail'] {
|
||||
.portfolio-section[data-layout="right-rail"] {
|
||||
inline-size: 60%;
|
||||
margin-inline: 36% 4%;
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='left-timeline'] {
|
||||
.portfolio-section[data-layout="left-timeline"] {
|
||||
inline-size: 60%;
|
||||
margin-inline: 4% 36%;
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='full-diagram'],
|
||||
.portfolio-section[data-layout='toolkit-field'] {
|
||||
.portfolio-section[data-layout="full-diagram"],
|
||||
.portfolio-section[data-layout="toolkit-field"] {
|
||||
inline-size: 84%;
|
||||
margin-inline: 8%;
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='project-stage'] {
|
||||
.portfolio-section[data-layout="project-stage"] {
|
||||
inline-size: 92%;
|
||||
margin-inline: 4%;
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='compact-rail'] {
|
||||
.portfolio-section[data-layout="compact-rail"] {
|
||||
inline-size: 62%;
|
||||
margin-inline: 34% 4%;
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='destination'] {
|
||||
.portfolio-section[data-layout="destination"] {
|
||||
inline-size: 70%;
|
||||
}
|
||||
}
|
||||
@@ -219,18 +224,23 @@
|
||||
|
||||
.site-navigation summary::after {
|
||||
color: var(--cyan);
|
||||
content: '+';
|
||||
content: "+";
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.site-navigation details[open] summary::after {
|
||||
content: '−';
|
||||
content: "−";
|
||||
}
|
||||
|
||||
.site-navigation summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-navigation summary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--focus);
|
||||
}
|
||||
|
||||
.site-navigation ul {
|
||||
position: absolute;
|
||||
inset: 100% 0 auto;
|
||||
@@ -271,14 +281,14 @@
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='project-stage'] {
|
||||
.portfolio-section[data-layout="project-stage"] {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.portfolio-section[data-layout='project-stage'] > h2,
|
||||
.portfolio-section[data-layout='project-stage'] > .section-eyebrow {
|
||||
.portfolio-section[data-layout="project-stage"] > h2,
|
||||
.portfolio-section[data-layout="project-stage"] > .section-eyebrow {
|
||||
margin-inline-start: 0;
|
||||
background: var(--surface-strong);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user