feat: present cleared accessible project media
This commit is contained in:
140
src/components/AccessibleVideo.vue
Normal file
140
src/components/AccessibleVideo.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from 'vue';
|
||||
import { useLazyMedia } from '@/composables/useLazyMedia';
|
||||
|
||||
export interface AccessibleVideoSource {
|
||||
src: string;
|
||||
type: 'video/mp4' | 'video/webm';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
sources: readonly AccessibleVideoSource[];
|
||||
poster: string;
|
||||
width: number;
|
||||
height: number;
|
||||
accessibleLabel?: string;
|
||||
caption: string;
|
||||
description: string;
|
||||
allowAutomatic: boolean;
|
||||
}>(), { accessibleLabel: 'Project video' });
|
||||
|
||||
const figure = ref<HTMLElement | null>(null);
|
||||
const video = ref<HTMLVideoElement | null>(null);
|
||||
const playing = shallowRef(false);
|
||||
const unavailable = shallowRef(false);
|
||||
const status = shallowRef('Video ready to play.');
|
||||
const savedTime = shallowRef(0);
|
||||
const automatic = computed(() => props.allowAutomatic && !unavailable.value);
|
||||
const lazy = useLazyMedia(figure, automatic);
|
||||
let failureTimer: number | undefined;
|
||||
|
||||
const clearFailureTimer = (): void => {
|
||||
if (failureTimer !== undefined) window.clearTimeout(failureTimer);
|
||||
failureTimer = undefined;
|
||||
};
|
||||
|
||||
const detach = async (): Promise<void> => {
|
||||
const element = video.value;
|
||||
if (element !== null) {
|
||||
savedTime.value = Number.isFinite(element.currentTime) ? element.currentTime : savedTime.value;
|
||||
element.pause();
|
||||
playing.value = false;
|
||||
status.value = unavailable.value ? status.value : 'Video paused while offscreen.';
|
||||
}
|
||||
await nextTick();
|
||||
video.value?.load();
|
||||
};
|
||||
|
||||
watch(() => lazy.shouldAttach.value, (attached, previous) => {
|
||||
if (!attached && previous) void detach();
|
||||
if (attached) void nextTick(() => video.value?.load());
|
||||
}, { flush: 'post' });
|
||||
|
||||
watch(() => props.allowAutomatic, (allowed) => {
|
||||
if (!allowed && !lazy.explicitRequested.value) void detach();
|
||||
});
|
||||
|
||||
const markUnavailable = (): void => {
|
||||
clearFailureTimer();
|
||||
unavailable.value = true;
|
||||
playing.value = false;
|
||||
lazy.clearExplicit();
|
||||
status.value = 'This video could not be played. The poster and description remain available.';
|
||||
};
|
||||
|
||||
const play = async (): Promise<void> => {
|
||||
if (unavailable.value) return;
|
||||
lazy.requestExplicit();
|
||||
status.value = 'Loading video.';
|
||||
await nextTick();
|
||||
const element = video.value;
|
||||
if (element === null) return markUnavailable();
|
||||
element.load();
|
||||
clearFailureTimer();
|
||||
failureTimer = window.setTimeout(markUnavailable, 3_000);
|
||||
try {
|
||||
await element.play();
|
||||
clearFailureTimer();
|
||||
playing.value = true;
|
||||
status.value = 'Video playing.';
|
||||
} catch {
|
||||
markUnavailable();
|
||||
}
|
||||
};
|
||||
|
||||
const pause = (): void => {
|
||||
video.value?.pause();
|
||||
playing.value = false;
|
||||
status.value = 'Video paused.';
|
||||
lazy.clearExplicit();
|
||||
};
|
||||
|
||||
const togglePlayback = (): void => {
|
||||
if (playing.value) pause();
|
||||
else void play();
|
||||
};
|
||||
|
||||
const restoreTime = (): void => {
|
||||
const element = video.value;
|
||||
if (element !== null && Number.isFinite(savedTime.value) && savedTime.value > 0) {
|
||||
element.currentTime = Math.min(savedTime.value, Number.isFinite(element.duration) ? element.duration : savedTime.value);
|
||||
}
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
clearFailureTimer();
|
||||
video.value?.pause();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure ref="figure" class="project-media project-media--video" data-project-media>
|
||||
<video
|
||||
ref="video"
|
||||
:poster="poster"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:aria-label="accessibleLabel"
|
||||
preload="none"
|
||||
playsinline
|
||||
@loadedmetadata="restoreTime"
|
||||
@error="markUnavailable"
|
||||
@ended="playing = false; status = 'Video ended.'"
|
||||
>
|
||||
<source
|
||||
v-for="source in lazy.shouldAttach.value ? sources : []"
|
||||
:key="source.src"
|
||||
:src="source.src"
|
||||
:type="source.type"
|
||||
/>
|
||||
</video>
|
||||
<figcaption>{{ caption }}</figcaption>
|
||||
<p class="media-description">{{ description }}</p>
|
||||
<div class="video-controls">
|
||||
<button type="button" :disabled="unavailable" @click="togglePlayback">
|
||||
{{ unavailable ? 'Video unavailable' : playing ? 'Pause video' : 'Play video' }}
|
||||
</button>
|
||||
<p data-testid="video-status">{{ status }}</p>
|
||||
</div>
|
||||
</figure>
|
||||
</template>
|
||||
@@ -169,11 +169,21 @@ onUnmounted(() => removePageListeners());
|
||||
</PortfolioSection>
|
||||
|
||||
<PortfolioSection id="selected-builds" :index="5" :eyebrow="sectionLabel('selected-builds').toUpperCase()" :heading="sectionLabel('selected-builds').toUpperCase()">
|
||||
<ProjectCaseStudy v-for="project in content.projects.selected" :key="project.id" :project="project" />
|
||||
<ProjectCaseStudy
|
||||
v-for="project in content.projects.selected"
|
||||
:key="project.id"
|
||||
:project="project"
|
||||
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
||||
/>
|
||||
</PortfolioSection>
|
||||
|
||||
<PortfolioSection id="additional-work" :index="6" :eyebrow="sectionLabel('additional-work').toUpperCase()" :heading="sectionLabel('additional-work').toUpperCase()">
|
||||
<ProjectCard v-for="project in content.projects.additional" :key="project.id" :project="project" />
|
||||
<ProjectCard
|
||||
v-for="project in content.projects.additional"
|
||||
:key="project.id"
|
||||
:project="project"
|
||||
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
||||
/>
|
||||
</PortfolioSection>
|
||||
|
||||
<PortfolioSection id="toolkit" :index="7" :eyebrow="sectionLabel('toolkit').toUpperCase()" :heading="sectionLabel('toolkit').toUpperCase()">
|
||||
|
||||
46
src/components/ChartFigure.vue
Normal file
46
src/components/ChartFigure.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartMediaPresentation } from '@/content/types';
|
||||
|
||||
defineProps<{
|
||||
presentation: Readonly<ChartMediaPresentation>;
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure class="project-media project-media--chart" data-project-media>
|
||||
<img
|
||||
:src="src"
|
||||
:alt="presentation.alt"
|
||||
:width="width"
|
||||
:height="height"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<figcaption>{{ presentation.caption }}</figcaption>
|
||||
<dl class="chart-description">
|
||||
<dt>Variables</dt>
|
||||
<dd data-testid="chart-variables">{{ presentation.chart.variables }}</dd>
|
||||
<dt>Trend</dt>
|
||||
<dd data-testid="chart-trend">{{ presentation.chart.trend }}</dd>
|
||||
<dt>Conclusion</dt>
|
||||
<dd data-testid="chart-conclusion">{{ presentation.chart.conclusion }}</dd>
|
||||
</dl>
|
||||
<table v-if="presentation.chart.table" class="chart-values">
|
||||
<caption>{{ presentation.chart.table.caption }}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in presentation.chart.table.columns" :key="column" scope="col">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in presentation.chart.table.rows" :key="rowIndex">
|
||||
<th scope="row">{{ row[0] }}</th>
|
||||
<td v-for="(value, cellIndex) in row.slice(1)" :key="cellIndex">{{ value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
</template>
|
||||
@@ -1,13 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicProject } from '@/content/types';
|
||||
import ProjectMedia from './ProjectMedia.vue';
|
||||
|
||||
defineProps<{ project: Readonly<PublicProject> }>();
|
||||
withDefaults(defineProps<{ project: Readonly<PublicProject>; allowAutomaticVideo?: boolean }>(), {
|
||||
allowAutomaticVideo: false,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article>
|
||||
<h3>{{ project.title }}</h3>
|
||||
<p>{{ project.summary }}</p>
|
||||
<ProjectMedia
|
||||
v-for="media in project.media ?? []"
|
||||
:key="`${media.kind}-${media.assetId}`"
|
||||
:presentation="media"
|
||||
:allow-automatic-video="allowAutomaticVideo"
|
||||
/>
|
||||
<p>{{ project.contribution }}</p>
|
||||
<p>{{ project.outcome }}</p>
|
||||
<a v-if="project.visibility === 'public' && project.url" class="project-link" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a>
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicProject } from '@/content/types';
|
||||
import ProjectMedia from './ProjectMedia.vue';
|
||||
|
||||
defineProps<{ project: Readonly<PublicProject> }>();
|
||||
withDefaults(defineProps<{ project: Readonly<PublicProject>; allowAutomaticVideo?: boolean }>(), {
|
||||
allowAutomaticVideo: false,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article>
|
||||
<h3>{{ project.title }}</h3>
|
||||
<p>{{ project.summary }}</p>
|
||||
<ProjectMedia
|
||||
v-for="media in project.media ?? []"
|
||||
:key="`${media.kind}-${media.assetId}`"
|
||||
:presentation="media"
|
||||
:allow-automatic-video="allowAutomaticVideo"
|
||||
/>
|
||||
<dl>
|
||||
<dt>Problem</dt>
|
||||
<dd>{{ project.problem }}</dd>
|
||||
|
||||
93
src/components/ProjectMedia.vue
Normal file
93
src/components/ProjectMedia.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue';
|
||||
import AccessibleVideo, { type AccessibleVideoSource } from './AccessibleVideo.vue';
|
||||
import ChartFigure from './ChartFigure.vue';
|
||||
import { productionInputsKey } from '@/app';
|
||||
import { assetUrl } from '@/content/asset-url';
|
||||
import type { MediaPresentation, PublicAsset, PublicAssetManifest } from '@/content/types';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
presentation: Readonly<MediaPresentation>;
|
||||
allowAutomaticVideo?: boolean;
|
||||
manifest?: Readonly<PublicAssetManifest>;
|
||||
}>(), { allowAutomaticVideo: false });
|
||||
|
||||
const inputs = inject(productionInputsKey, undefined);
|
||||
const resolvedManifest = computed(() => props.manifest ?? inputs?.manifest);
|
||||
|
||||
const resolveAsset = (assetId: string): PublicAsset => {
|
||||
const manifest = resolvedManifest.value;
|
||||
if (manifest === undefined) throw new Error('Project media requires the public asset manifest.');
|
||||
const asset = manifest.assets.find((candidate) => candidate.id === assetId);
|
||||
if (asset === undefined) throw new Error(`Unknown asset ID: ${assetId}`);
|
||||
return asset;
|
||||
};
|
||||
|
||||
const primaryAsset = computed(() => resolveAsset(props.presentation.assetId));
|
||||
const primaryUrl = computed(() => assetUrl(resolvedManifest.value!, props.presentation.assetId));
|
||||
const assertImage = (asset: PublicAsset): PublicAsset => {
|
||||
if (!asset.mediaType.startsWith('image/') || asset.width === null || asset.height === null) {
|
||||
throw new Error(`Project media asset is not an intrinsic-size image: ${asset.id}`);
|
||||
}
|
||||
return asset;
|
||||
};
|
||||
const imageAsset = computed(() => assertImage(primaryAsset.value));
|
||||
const videoSources = computed<readonly AccessibleVideoSource[]>(() => {
|
||||
if (props.presentation.kind !== 'video') return [];
|
||||
const ids = [props.presentation.assetId, props.presentation.fallbackAssetId].filter((id): id is string => id !== undefined);
|
||||
return ids.map((id) => {
|
||||
const asset = resolveAsset(id);
|
||||
if (asset.mediaType !== 'video/webm' && asset.mediaType !== 'video/mp4') {
|
||||
throw new Error(`Project video source has an invalid media type: ${id}`);
|
||||
}
|
||||
return { src: assetUrl(resolvedManifest.value!, id), type: asset.mediaType };
|
||||
});
|
||||
});
|
||||
const videoAsset = computed(() => {
|
||||
const asset = primaryAsset.value;
|
||||
if ((asset.mediaType !== 'video/webm' && asset.mediaType !== 'video/mp4') || asset.width === null || asset.height === null) {
|
||||
throw new Error(`Project media asset is not an intrinsic-size video: ${asset.id}`);
|
||||
}
|
||||
return asset;
|
||||
});
|
||||
const posterUrl = computed(() => {
|
||||
if (props.presentation.kind !== 'video') return '';
|
||||
assertImage(resolveAsset(props.presentation.posterAssetId));
|
||||
return assetUrl(resolvedManifest.value!, props.presentation.posterAssetId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartFigure
|
||||
v-if="presentation.kind === 'chart'"
|
||||
:presentation="presentation"
|
||||
:src="primaryUrl"
|
||||
:width="imageAsset.width!"
|
||||
:height="imageAsset.height!"
|
||||
/>
|
||||
<AccessibleVideo
|
||||
v-else-if="presentation.kind === 'video'"
|
||||
:sources="videoSources"
|
||||
:poster="posterUrl"
|
||||
:width="videoAsset.width!"
|
||||
:height="videoAsset.height!"
|
||||
:accessible-label="presentation.alt"
|
||||
:caption="presentation.caption"
|
||||
:description="presentation.longDescription"
|
||||
:allow-automatic="allowAutomaticVideo"
|
||||
/>
|
||||
<figure v-else class="project-media project-media--image" data-project-media>
|
||||
<img
|
||||
:src="primaryUrl"
|
||||
:alt="presentation.alt"
|
||||
:width="imageAsset.width!"
|
||||
:height="imageAsset.height!"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<figcaption v-if="presentation.caption">{{ presentation.caption }}</figcaption>
|
||||
<p v-if="presentation.longDescription" class="media-description" data-testid="media-long-description">
|
||||
{{ presentation.longDescription }}
|
||||
</p>
|
||||
</figure>
|
||||
</template>
|
||||
48
src/composables/useLazyMedia.ts
Normal file
48
src/composables/useLazyMedia.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { computed, onMounted, onUnmounted, readonly, shallowRef, unref, type MaybeRef, type Ref } from 'vue';
|
||||
|
||||
export interface LazyMediaState {
|
||||
nearby: Readonly<Ref<boolean>>;
|
||||
shouldAttach: Readonly<Ref<boolean>>;
|
||||
explicitRequested: Readonly<Ref<boolean>>;
|
||||
requestExplicit(): void;
|
||||
clearExplicit(): void;
|
||||
}
|
||||
|
||||
export const MEDIA_ACTIVATION_MARGIN = '300px 0px 300px 0px';
|
||||
|
||||
export function useLazyMedia(
|
||||
target: Readonly<Ref<Element | null>>,
|
||||
allowAutomatic: MaybeRef<boolean>,
|
||||
): LazyMediaState {
|
||||
const nearby = shallowRef(false);
|
||||
const explicitRequested = shallowRef(false);
|
||||
const shouldAttach = computed(() => explicitRequested.value || (nearby.value && unref(allowAutomatic)));
|
||||
let observer: IntersectionObserver | undefined;
|
||||
|
||||
const requestExplicit = (): void => {
|
||||
explicitRequested.value = true;
|
||||
};
|
||||
const clearExplicit = (): void => {
|
||||
explicitRequested.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof IntersectionObserver !== 'function' || target.value === null) return;
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
const inActivationRange = entries.some((entry) => entry.isIntersecting);
|
||||
nearby.value = inActivationRange;
|
||||
if (!inActivationRange) clearExplicit();
|
||||
}, { rootMargin: MEDIA_ACTIVATION_MARGIN, threshold: 0 });
|
||||
observer.observe(target.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => observer?.disconnect());
|
||||
|
||||
return {
|
||||
nearby: readonly(nearby),
|
||||
shouldAttach: readonly(shouldAttach),
|
||||
explicitRequested: readonly(explicitRequested),
|
||||
requestExplicit,
|
||||
clearExplicit,
|
||||
};
|
||||
}
|
||||
@@ -58,6 +58,46 @@ const navigationItemSchema = z.object({
|
||||
id: z.enum(canonicalNavigationIds),
|
||||
label: visibleString,
|
||||
}).strict();
|
||||
|
||||
const mediaPresentationBase = {
|
||||
assetId: opaqueId,
|
||||
alt: visibleString,
|
||||
caption: visibleString.optional(),
|
||||
longDescription: visibleString.optional(),
|
||||
};
|
||||
const mediaTableSchema = z.object({
|
||||
caption: visibleString,
|
||||
columns: z.array(visibleString).min(2),
|
||||
rows: z.array(z.array(visibleString).min(2)).min(1),
|
||||
}).strict().superRefine((table, context) => {
|
||||
for (const [index, row] of table.rows.entries()) {
|
||||
if (row.length !== table.columns.length) {
|
||||
context.addIssue({ code: 'custom', message: 'Every chart table row must match the column count.', path: ['rows', index] });
|
||||
}
|
||||
}
|
||||
});
|
||||
const mediaPresentationSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('image'), ...mediaPresentationBase }).strict(),
|
||||
z.object({
|
||||
kind: z.literal('video'),
|
||||
...mediaPresentationBase,
|
||||
fallbackAssetId: opaqueId.optional(),
|
||||
posterAssetId: opaqueId,
|
||||
caption: visibleString,
|
||||
longDescription: visibleString,
|
||||
}).strict(),
|
||||
z.object({
|
||||
kind: z.literal('chart'),
|
||||
...mediaPresentationBase,
|
||||
caption: visibleString,
|
||||
chart: z.object({
|
||||
variables: visibleString,
|
||||
trend: visibleString,
|
||||
conclusion: visibleString,
|
||||
table: mediaTableSchema.optional(),
|
||||
}).strict(),
|
||||
}).strict(),
|
||||
]);
|
||||
const projectSchema = z.object({
|
||||
id: opaqueId,
|
||||
title: visibleString,
|
||||
@@ -70,6 +110,7 @@ const projectSchema = z.object({
|
||||
outcome: visibleString,
|
||||
url: httpUrl.optional(),
|
||||
assetIds: z.array(opaqueId),
|
||||
media: z.array(mediaPresentationSchema).optional(),
|
||||
}).strict().superRefine((project, context) => {
|
||||
if (project.visibility === 'private' && project.url !== undefined) {
|
||||
context.addIssue({ code: 'custom', message: 'A private project may not include a url.', path: ['url'] });
|
||||
@@ -77,6 +118,21 @@ const projectSchema = z.object({
|
||||
if (project.visibility === 'private' && !approvedPrivateProjectTitles.includes(project.title as (typeof approvedPrivateProjectTitles)[number])) {
|
||||
context.addIssue({ code: 'custom', message: 'Private project title must use an approved generic title.', path: ['title'] });
|
||||
}
|
||||
if (project.media !== undefined) {
|
||||
const mediaAssetIds = project.media.flatMap((media) => media.kind === 'video'
|
||||
? [media.assetId, ...(media.fallbackAssetId === undefined ? [] : [media.fallbackAssetId]), media.posterAssetId]
|
||||
: [media.assetId]);
|
||||
const declared = [...project.assetIds].sort();
|
||||
const presented = [...mediaAssetIds].sort();
|
||||
if (new Set(declared).size !== declared.length || new Set(presented).size !== presented.length ||
|
||||
JSON.stringify(declared) !== JSON.stringify(presented)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Project media must have an exact, duplicate-free asset closure.',
|
||||
path: ['assetIds'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const portfolioContentSchema = z.object({
|
||||
|
||||
@@ -11,6 +11,47 @@ export type SectionId =
|
||||
export type ProjectVisibility = 'public' | 'private';
|
||||
export type ProjectMaturity = 'learning' | 'experimental' | 'prototype' | 'work-in-progress' | 'active';
|
||||
|
||||
export interface MediaPresentationBase {
|
||||
assetId: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
longDescription?: string;
|
||||
}
|
||||
|
||||
export interface ImageMediaPresentation extends MediaPresentationBase {
|
||||
kind: 'image';
|
||||
}
|
||||
|
||||
export interface VideoMediaPresentation extends MediaPresentationBase {
|
||||
kind: 'video';
|
||||
fallbackAssetId?: string;
|
||||
posterAssetId: string;
|
||||
caption: string;
|
||||
longDescription: string;
|
||||
}
|
||||
|
||||
export interface ChartValueTable {
|
||||
caption: string;
|
||||
columns: readonly string[];
|
||||
rows: ReadonlyArray<readonly string[]>;
|
||||
}
|
||||
|
||||
export interface ChartMediaPresentation extends MediaPresentationBase {
|
||||
kind: 'chart';
|
||||
caption: string;
|
||||
chart: {
|
||||
variables: string;
|
||||
trend: string;
|
||||
conclusion: string;
|
||||
table?: ChartValueTable;
|
||||
};
|
||||
}
|
||||
|
||||
export type MediaPresentation =
|
||||
| ImageMediaPresentation
|
||||
| VideoMediaPresentation
|
||||
| ChartMediaPresentation;
|
||||
|
||||
export interface ExperienceRecord {
|
||||
id: string;
|
||||
role: string;
|
||||
@@ -52,6 +93,7 @@ export interface PublicProject {
|
||||
outcome: string;
|
||||
url?: string;
|
||||
assetIds: readonly string[];
|
||||
media?: readonly MediaPresentation[];
|
||||
}
|
||||
|
||||
export interface ToolkitTechnology {
|
||||
|
||||
@@ -74,6 +74,79 @@
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
|
||||
.project-media {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-media img,
|
||||
.project-media video {
|
||||
inline-size: 100%;
|
||||
block-size: auto;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.media-description,
|
||||
.chart-description,
|
||||
.video-controls,
|
||||
.chart-values {
|
||||
margin-block-start: var(--space-2);
|
||||
}
|
||||
|
||||
.chart-description {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6rem, auto) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.chart-values {
|
||||
inline-size: 100%;
|
||||
border-collapse: collapse;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chart-values caption {
|
||||
padding-block-end: var(--space-1);
|
||||
color: var(--text);
|
||||
font-weight: 650;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.chart-values th,
|
||||
.chart-values td {
|
||||
padding: 0.45rem;
|
||||
border: 1px solid var(--line);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video-controls button {
|
||||
min-inline-size: 8rem;
|
||||
min-block-size: 44px;
|
||||
padding-inline: var(--space-2);
|
||||
border: 1px solid var(--cyan);
|
||||
background: var(--cyan-deep);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.video-controls button:disabled {
|
||||
border-color: var(--line);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.chart-description {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portfolio-section svg {
|
||||
inline-size: 100%;
|
||||
block-size: auto;
|
||||
|
||||
28
tests/browser/media-policy.spec.ts
Normal file
28
tests/browser/media-policy.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from '../helpers/serve-dist';
|
||||
|
||||
test('pending approval renders a complete text-first portfolio without media requests or broken placeholders', async ({ page, distUrl }) => {
|
||||
const mediaRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (/\.(?:avif|jpe?g|png|webp|mp4|webm)(?:$|\?)/iu.test(request.url())) mediaRequests.push(request.url());
|
||||
});
|
||||
await page.goto(distUrl, { waitUntil: 'networkidle' });
|
||||
|
||||
await expect(page.locator('main > section')).toHaveCount(8);
|
||||
await expect(page.locator('#selected-builds article')).toHaveCount(3);
|
||||
await expect(page.locator('#additional-work article')).toHaveCount(4);
|
||||
await expect(page.locator('[data-project-media]')).toHaveCount(0);
|
||||
await expect(page.locator('.journey-poster')).not.toHaveAttribute('src', /.+/u);
|
||||
expect(mediaRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test('reduced motion does not introduce automatic project media payloads', async ({ page, distUrl }) => {
|
||||
const videoRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (/\.(?:mp4|webm)(?:$|\?)/iu.test(request.url())) videoRequests.push(request.url());
|
||||
});
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(distUrl, { waitUntil: 'networkidle' });
|
||||
|
||||
await expect(page.locator('video[src], video source[src]')).toHaveCount(0);
|
||||
expect(videoRequests).toEqual([]);
|
||||
});
|
||||
274
tests/unit/ProjectMedia.test.ts
Normal file
274
tests/unit/ProjectMedia.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { productionInputsKey } from '@/app';
|
||||
import AccessibleVideo from '@/components/AccessibleVideo.vue';
|
||||
import ProjectMedia from '@/components/ProjectMedia.vue';
|
||||
import { useLazyMedia } from '@/composables/useLazyMedia';
|
||||
import { parsePortfolioContent, parsePublicAssetManifest } from '@/content/schemas';
|
||||
import type { MediaPresentation, ProductionInputs, PublicAssetManifest } from '@/content/types';
|
||||
|
||||
const sha = 'a'.repeat(64);
|
||||
const manifest: PublicAssetManifest = {
|
||||
assets: [
|
||||
{
|
||||
id: 'project-image', fileName: 'project-image.webp', sha256: sha, mediaType: 'image/webp',
|
||||
width: 1200, height: 800, durationMs: null, rightsHolder: 'Luca Pîslaru',
|
||||
license: 'All rights reserved', attribution: 'Luca Pîslaru',
|
||||
},
|
||||
{
|
||||
id: 'project-chart', fileName: 'project-chart.webp', sha256: sha, mediaType: 'image/webp',
|
||||
width: 1000, height: 700, durationMs: null, rightsHolder: 'Luca Pîslaru',
|
||||
license: 'All rights reserved', attribution: 'Luca Pîslaru',
|
||||
},
|
||||
{
|
||||
id: 'project-loop', fileName: 'project-loop.webm', sha256: sha, mediaType: 'video/webm',
|
||||
width: 1280, height: 720, durationMs: 5_000, rightsHolder: 'Luca Pîslaru',
|
||||
license: 'All rights reserved', attribution: 'Luca Pîslaru',
|
||||
},
|
||||
{
|
||||
id: 'project-loop-mp4', fileName: 'project-loop.mp4', sha256: sha, mediaType: 'video/mp4',
|
||||
width: 1280, height: 720, durationMs: 5_000, rightsHolder: 'Luca Pîslaru',
|
||||
license: 'All rights reserved', attribution: 'Luca Pîslaru',
|
||||
},
|
||||
{
|
||||
id: 'project-loop-poster', fileName: 'project-loop-poster.webp', sha256: sha, mediaType: 'image/webp',
|
||||
width: 1280, height: 720, durationMs: null, rightsHolder: 'Luca Pîslaru',
|
||||
license: 'All rights reserved', attribution: 'Luca Pîslaru',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const injectedInputs = {
|
||||
manifest,
|
||||
content: {},
|
||||
site: {},
|
||||
} as unknown as ProductionInputs;
|
||||
|
||||
class IntersectionObserverFake {
|
||||
static instances: IntersectionObserverFake[] = [];
|
||||
readonly observe = vi.fn();
|
||||
readonly disconnect = vi.fn();
|
||||
constructor(
|
||||
readonly callback: IntersectionObserverCallback,
|
||||
readonly options?: IntersectionObserverInit,
|
||||
) {
|
||||
IntersectionObserverFake.instances.push(this);
|
||||
}
|
||||
unobserve(): void {}
|
||||
takeRecords(): IntersectionObserverEntry[] { return []; }
|
||||
readonly root = null;
|
||||
readonly rootMargin = '300px 0px 300px 0px';
|
||||
readonly thresholds = [0];
|
||||
emit(isIntersecting: boolean): void {
|
||||
this.callback([{ isIntersecting } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
IntersectionObserverFake.instances = [];
|
||||
vi.stubGlobal('IntersectionObserver', IntersectionObserverFake);
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const mountMedia = (presentation: MediaPresentation, allowAutomaticVideo = false) => mount(ProjectMedia, {
|
||||
props: { presentation, allowAutomaticVideo },
|
||||
global: { provide: { [productionInputsKey as symbol]: injectedInputs } },
|
||||
});
|
||||
|
||||
describe('ProjectMedia', () => {
|
||||
it('resolves only manifest assets and keeps contextual wording in content', () => {
|
||||
const wrapper = mountMedia({
|
||||
kind: 'image',
|
||||
assetId: 'project-image',
|
||||
alt: 'A fictional application schedule on a phone.',
|
||||
caption: 'A sanitized mobile workflow.',
|
||||
longDescription: 'The interface groups the daily schedule and stock status.',
|
||||
});
|
||||
|
||||
const image = wrapper.get('img');
|
||||
expect(image.attributes()).toMatchObject({
|
||||
src: '/assets/project-image.webp',
|
||||
alt: 'A fictional application schedule on a phone.',
|
||||
width: '1200',
|
||||
height: '800',
|
||||
loading: 'lazy',
|
||||
});
|
||||
expect(wrapper.get('figcaption').text()).toBe('A sanitized mobile workflow.');
|
||||
expect(wrapper.get('[data-testid="media-long-description"]').text()).toContain('daily schedule');
|
||||
|
||||
expect(() => mountMedia({ kind: 'image', assetId: 'not-in-manifest', alt: 'Unknown asset.' }))
|
||||
.toThrow(/unknown asset id/i);
|
||||
});
|
||||
|
||||
it('renders chart variables, trend, conclusion, and exact-value table as visible text', () => {
|
||||
const wrapper = mountMedia({
|
||||
kind: 'chart',
|
||||
assetId: 'project-chart',
|
||||
alt: 'A line chart of polarization over simulation steps.',
|
||||
caption: 'Polarization rises during this modeled run.',
|
||||
chart: {
|
||||
variables: 'Horizontal: simulation step. Vertical: polarization index.',
|
||||
trend: 'The index rises sharply, then increases gradually.',
|
||||
conclusion: 'This run ends more polarized than it began.',
|
||||
table: {
|
||||
caption: 'Selected modeled values',
|
||||
columns: ['Step', 'Polarization index'],
|
||||
rows: [['0', '0.765'], ['14', '0.932']],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.get('figcaption').text()).toContain('Polarization rises');
|
||||
expect(wrapper.get('[data-testid="chart-variables"]').text()).toContain('simulation step');
|
||||
expect(wrapper.get('[data-testid="chart-trend"]').text()).toContain('rises sharply');
|
||||
expect(wrapper.get('[data-testid="chart-conclusion"]').text()).toContain('more polarized');
|
||||
expect(wrapper.get('table caption').text()).toBe('Selected modeled values');
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps video sources detached until explicit Play when automatic media is blocked', async () => {
|
||||
const wrapper = mountMedia({
|
||||
kind: 'video',
|
||||
assetId: 'project-loop',
|
||||
fallbackAssetId: 'project-loop-mp4',
|
||||
posterAssetId: 'project-loop-poster',
|
||||
alt: 'Gameplay from a private prototype.',
|
||||
caption: 'A short gameplay-only excerpt.',
|
||||
longDescription: 'The player navigates a compact prototype environment.',
|
||||
});
|
||||
|
||||
const video = wrapper.get('video');
|
||||
expect(video.attributes('preload')).toBe('none');
|
||||
expect(video.attributes()).not.toHaveProperty('controls');
|
||||
expect(video.findAll('source')).toHaveLength(0);
|
||||
expect(wrapper.get('button').text()).toBe('Play video');
|
||||
|
||||
IntersectionObserverFake.instances[0]!.emit(true);
|
||||
await nextTick();
|
||||
expect(video.findAll('source')).toHaveLength(0);
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
await nextTick();
|
||||
expect(video.findAll('source').map((source) => source.attributes('src'))).toEqual([
|
||||
'/assets/project-loop.webm',
|
||||
'/assets/project-loop.mp4',
|
||||
]);
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('attaches inside 300 CSS pixels when automatic media is allowed and detaches offscreen', async () => {
|
||||
const wrapper = mountMedia({
|
||||
kind: 'video', assetId: 'project-loop', posterAssetId: 'project-loop-poster',
|
||||
alt: 'Gameplay excerpt.', caption: 'Gameplay.', longDescription: 'A gameplay loop.',
|
||||
}, true);
|
||||
const observer = IntersectionObserverFake.instances[0]!;
|
||||
expect(observer.options?.rootMargin).toBe('300px 0px 300px 0px');
|
||||
|
||||
observer.emit(true);
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('video source')).toHaveLength(1);
|
||||
|
||||
const video = wrapper.get('video').element as HTMLVideoElement;
|
||||
video.currentTime = 2.25;
|
||||
|
||||
observer.emit(false);
|
||||
await nextTick();
|
||||
expect(wrapper.findAll('video source')).toHaveLength(0);
|
||||
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled();
|
||||
|
||||
video.currentTime = 0;
|
||||
observer.emit(true);
|
||||
await nextTick();
|
||||
await wrapper.get('video').trigger('loadedmetadata');
|
||||
expect(video.currentTime).toBe(2.25);
|
||||
});
|
||||
|
||||
it('reports playback failure without exposing native controls', async () => {
|
||||
vi.mocked(HTMLMediaElement.prototype.play).mockRejectedValueOnce(new Error('blocked'));
|
||||
const wrapper = mount(AccessibleVideo, {
|
||||
props: {
|
||||
sources: [{ src: '/assets/project-loop.webm', type: 'video/webm' }],
|
||||
poster: '/assets/project-loop-poster.webp',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
caption: 'Gameplay.',
|
||||
description: 'A gameplay loop.',
|
||||
allowAutomatic: false,
|
||||
},
|
||||
});
|
||||
IntersectionObserverFake.instances[0]!.emit(true);
|
||||
await wrapper.get('button').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.get('button').text()).toBe('Video unavailable');
|
||||
expect(wrapper.get('[data-testid="video-status"]').text()).toContain('could not be played');
|
||||
expect(wrapper.get('video').attributes()).not.toHaveProperty('controls');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLazyMedia', () => {
|
||||
it('fails closed without an observer and supports an explicit request', async () => {
|
||||
vi.stubGlobal('IntersectionObserver', undefined);
|
||||
const target = ref<Element | null>(document.createElement('div'));
|
||||
const wrapper = mount({
|
||||
setup() {
|
||||
return useLazyMedia(target, ref(true));
|
||||
},
|
||||
template: '<div />',
|
||||
});
|
||||
expect((wrapper.vm as unknown as { shouldAttach: boolean }).shouldAttach).toBe(false);
|
||||
(wrapper.vm as unknown as { requestExplicit: () => void }).requestExplicit();
|
||||
await nextTick();
|
||||
expect((wrapper.vm as unknown as { shouldAttach: boolean }).shouldAttach).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('media content schema', () => {
|
||||
it('requires content-owned descriptions and exact project asset closure', () => {
|
||||
const raw = JSON.parse(JSON.stringify({
|
||||
identity: { name: 'Luca Pîslaru', role: 'System Administrator & Full-Stack Developer', valueProposition: 'Value.' },
|
||||
navigation: ['start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact']
|
||||
.map((id) => ({ id, label: id })),
|
||||
profile: { introduction: 'Profile.', screeningFacts: [] },
|
||||
experience: [{ id: 'role', role: 'Role', organization: 'Org', dates: 'Jan 2024 – Present', highlights: ['One.', 'Two.'] }],
|
||||
systemsOperations: { id: 'systems', title: 'Professional systems operations', summary: 'Summary.', evidence: ['Evidence.'] },
|
||||
infrastructure: {
|
||||
id: 'infra', title: 'Secure self-hosted platform and lifecycle', summary: 'Summary.',
|
||||
bands: ['one', 'two', 'three', 'four'].map((id) => ({ id, title: id, summary: 'Summary.' })),
|
||||
},
|
||||
projects: {
|
||||
selected: [
|
||||
{ id: 'private', title: 'Private self-hosted medication tracker', visibility: 'private', maturity: 'active', summary: 'S.', problem: 'P.', contribution: 'C.', technicalDecisions: ['D.'], outcome: 'O.', assetIds: ['project-image'], media: [{ kind: 'image', assetId: 'project-image', alt: 'Contextual alternative.' }] },
|
||||
{ id: 'footy', title: 'Footy Tracker', visibility: 'public', maturity: 'work-in-progress', summary: 'S.', problem: 'P.', contribution: 'C.', technicalDecisions: ['D.'], outcome: 'O.', assetIds: [], media: [] },
|
||||
{ id: 'research', title: 'Opinion Dynamics Simulation', visibility: 'public', maturity: 'experimental', summary: 'S.', problem: 'P.', contribution: 'C.', technicalDecisions: ['D.'], outcome: 'O.', assetIds: [], media: [] },
|
||||
],
|
||||
additional: [{ id: 'extra', title: 'Extra', visibility: 'public', maturity: 'learning', summary: 'S.', problem: 'P.', contribution: 'C.', technicalDecisions: ['D.'], outcome: 'O.', assetIds: [], media: [] }],
|
||||
},
|
||||
toolkit: [{ title: 'Tools', technologies: [{ name: 'Linux' }] }],
|
||||
contact: { email: 'hello@example.test', social: [], cvMessage: 'Contact directly.' },
|
||||
metadata: { title: 'Title.', description: 'Description.' },
|
||||
sceneDescription: 'Scene.',
|
||||
}));
|
||||
|
||||
expect(parsePortfolioContent(raw).projects.selected[0]!.media?.[0]!.alt).toBe('Contextual alternative.');
|
||||
raw.projects.selected[0].media[0].manifestCaption = 'Forbidden authority.';
|
||||
expect(() => parsePortfolioContent(raw)).toThrow(/unrecognized/i);
|
||||
delete raw.projects.selected[0].media[0].manifestCaption;
|
||||
raw.projects.selected[0].assetIds = [];
|
||||
expect(() => parsePortfolioContent(raw)).toThrow(/asset closure/i);
|
||||
});
|
||||
|
||||
it('rejects contextual copy on manifest records', () => {
|
||||
const raw = structuredClone(manifest) as unknown as { assets: Array<Record<string, unknown>> };
|
||||
raw.assets[0]!.alt = 'Forbidden manifest alternative.';
|
||||
expect(() => parsePublicAssetManifest(raw)).toThrow(/unrecognized/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user