feat: present cleared accessible project media

This commit is contained in:
2026-09-02 06:50:00 +03:00
parent dfd56f0a48
commit f953905a4b
12 changed files with 832 additions and 4 deletions

View 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>

View File

@@ -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()">

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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,
};
}

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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;