feat: refine portfolio content, contact, and supporting media
This commit is contained in:
@@ -8,6 +8,7 @@ import './styles/components.css';
|
||||
import './styles/world.css';
|
||||
import './styles/evolution.css';
|
||||
import './styles/chromatic.css';
|
||||
import './styles/refinements.css';
|
||||
import './styles/print.css';
|
||||
|
||||
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
|
||||
import { useLazyMedia } from '@/composables/useLazyMedia';
|
||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||
|
||||
@@ -28,8 +28,12 @@ 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);
|
||||
const userPaused = ref(false);
|
||||
const pageVisible = ref(true);
|
||||
const enhanced = ref(false);
|
||||
const automatic = computed(() => props.allowAutomatic && !unavailable.value && !userPaused.value && pageVisible.value);
|
||||
const lazy = useLazyMedia(figure, automatic, '0px');
|
||||
const fallbackSource = computed(() => props.sources.find(({ type }) => type === 'video/mp4') ?? props.sources[0]);
|
||||
let failureTimer: number | undefined;
|
||||
|
||||
const clearFailureTimer = (): void => {
|
||||
@@ -51,7 +55,10 @@ const detach = async (): Promise<void> => {
|
||||
|
||||
watch(() => lazy.shouldAttach.value, (attached, previous) => {
|
||||
if (!attached && previous) void detach();
|
||||
if (attached) void nextTick(() => video.value?.load());
|
||||
if (attached) void nextTick(() => {
|
||||
// Explicit Play loads its sources before play(); a later load would abort it.
|
||||
if (!lazy.explicitRequested.value) video.value?.load();
|
||||
});
|
||||
}, { flush: 'post' });
|
||||
|
||||
watch(() => props.allowAutomatic, (allowed) => {
|
||||
@@ -92,6 +99,7 @@ const play = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
const pause = (): void => {
|
||||
userPaused.value = true;
|
||||
video.value?.pause();
|
||||
playing.value = false;
|
||||
status.value = 'Video paused.';
|
||||
@@ -100,9 +108,25 @@ const pause = (): void => {
|
||||
|
||||
const togglePlayback = (): void => {
|
||||
if (playing.value) pause();
|
||||
else void play();
|
||||
else {
|
||||
userPaused.value = false;
|
||||
void play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibility = (): void => {
|
||||
pageVisible.value = !document.hidden;
|
||||
if (!pageVisible.value) {
|
||||
video.value?.pause();
|
||||
lazy.clearExplicit();
|
||||
}
|
||||
};
|
||||
onMounted(() => {
|
||||
enhanced.value = true;
|
||||
handleVisibility();
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
});
|
||||
|
||||
const restoreTime = (): void => {
|
||||
const element = video.value;
|
||||
if (element !== null && Number.isFinite(savedTime.value) && savedTime.value > 0) {
|
||||
@@ -111,13 +135,14 @@ const restoreTime = (): void => {
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
clearFailureTimer();
|
||||
video.value?.pause();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure ref="figure" class="project-media project-media--video" data-project-media>
|
||||
<figure ref="figure" class="project-media project-media--video" data-project-media :data-enhanced="enhanced">
|
||||
<video
|
||||
ref="video"
|
||||
:poster="poster"
|
||||
@@ -125,7 +150,12 @@ onUnmounted(() => {
|
||||
:height="height"
|
||||
:aria-label="accessibleLabel"
|
||||
preload="none"
|
||||
muted
|
||||
loop
|
||||
:autoplay="automatic"
|
||||
playsinline
|
||||
@play="playing = true; status = 'Video playing.'"
|
||||
@pause="playing = false"
|
||||
@loadedmetadata="restoreTime"
|
||||
@error="markUnavailable"
|
||||
@ended="playing = false; status = 'Video ended.'"
|
||||
@@ -145,5 +175,6 @@ onUnmounted(() => {
|
||||
</button>
|
||||
<p data-testid="video-status">{{ status }}</p>
|
||||
</div>
|
||||
<a v-if="fallbackSource" class="video-fallback" :href="fallbackSource.src" target="_blank" rel="noopener noreferrer">Open gameplay video</a>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
@@ -226,7 +226,7 @@ onUnmounted(() => removePageListeners());
|
||||
<div class="reading-stage">
|
||||
<div class="reading-viewport">
|
||||
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
||||
<HeroJourney :identity="content.identity" :email="content.contact.email" @explore="navigation.onSectionLinkClick" />
|
||||
<HeroJourney :identity="content.identity" @explore="navigation.onSectionLinkClick" @contact="navigation.onSectionLinkClick" />
|
||||
|
||||
<PortfolioSection id="profile" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
|
||||
<p>{{ content.profile.introduction }}</p>
|
||||
|
||||
@@ -1,17 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from 'vue';
|
||||
import BrandIcon from './BrandIcon.vue';
|
||||
import { productionInputsKey } from '@/app';
|
||||
import { assetUrl } from '@/content/asset-url';
|
||||
import { contactAddress, readableContactAddress } from '@/content/contact-email';
|
||||
import type { ContactContent } from '@/content/types';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
contact: Readonly<ContactContent>;
|
||||
}>();
|
||||
const inputs = inject(productionInputsKey, undefined);
|
||||
const avatar = computed(() => inputs?.manifest.assets.find(({ id }) => id === props.contact.avatarAssetId));
|
||||
const avatarUrl = computed(() => avatar.value && inputs ? assetUrl(inputs.manifest, avatar.value.id) : undefined);
|
||||
const enhanced = ref(false);
|
||||
const details = ref<HTMLDetailsElement | null>(null);
|
||||
const revealed = ref(false);
|
||||
const address = ref('');
|
||||
const copyStatus = ref('');
|
||||
const readableAddress = computed(() => readableContactAddress(props.contact.email));
|
||||
|
||||
onMounted(() => { enhanced.value = true; });
|
||||
const revealAddress = (): void => {
|
||||
address.value = contactAddress(props.contact.email);
|
||||
};
|
||||
const handleToggle = (): void => {
|
||||
revealed.value = details.value?.open ?? false;
|
||||
copyStatus.value = '';
|
||||
if (revealed.value) revealAddress();
|
||||
};
|
||||
const closeDisclosure = (): void => {
|
||||
if (details.value?.open) {
|
||||
details.value.open = false;
|
||||
details.value.querySelector('summary')?.focus();
|
||||
}
|
||||
};
|
||||
const copyAddress = async (): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(contactAddress(props.contact.email));
|
||||
copyStatus.value = 'Copied';
|
||||
} catch {
|
||||
copyStatus.value = 'Select the address to copy it.';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img v-if="avatar && avatarUrl" class="contact-avatar" :src="avatarUrl" alt="Illustrated avatar" :width="avatar.width!" :height="avatar.height!" loading="lazy" decoding="async" />
|
||||
<div class="contact-destination">
|
||||
<p class="contact-destination__copy">{{ contact.introduction }}</p>
|
||||
<a class="contact-link" :href="`mailto:${contact.email}`">Email Luca</a>
|
||||
<p class="contact-destination__email">{{ contact.email }}</p>
|
||||
<div class="contact-email" :data-enhanced="enhanced" :data-revealed="revealed" @keydown.esc="closeDisclosure">
|
||||
<div class="contact-email__action" @pointerenter="revealAddress" @focusin="revealAddress">
|
||||
<a v-if="enhanced" class="contact-link" :href="address ? `mailto:${address}` : '#contact-email-details'" @pointerdown="revealAddress" @click="revealAddress">Email Me</a>
|
||||
<span v-if="enhanced" class="contact-email__preview" aria-hidden="true">{{ address }}</span>
|
||||
</div>
|
||||
<details id="contact-email-details" ref="details" class="contact-email__details" @toggle="handleToggle">
|
||||
<summary>{{ enhanced ? (revealed ? 'Hide email' : 'Show email') : 'Email Me' }}</summary>
|
||||
<div class="contact-email__disclosure">
|
||||
<span class="contact-email__address">{{ enhanced && address ? address : readableAddress }}</span>
|
||||
<button v-if="enhanced" type="button" class="contact-email__copy" @click="copyAddress">Copy email</button>
|
||||
<span v-if="enhanced" class="contact-email__status" role="status">{{ copyStatus }}</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<p class="contact-destination__cv">{{ contact.cvMessage }}</p>
|
||||
</div>
|
||||
<ul class="contact-social">
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { PortfolioContent } from '@/content/types';
|
||||
|
||||
defineProps<{
|
||||
identity: Readonly<PortfolioContent['identity']>;
|
||||
email: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
explore: [event: MouseEvent];
|
||||
contact: [event: MouseEvent];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,7 @@ const emit = defineEmits<{
|
||||
<p class="hero-journey__value">{{ identity.valueProposition }}</p>
|
||||
<p class="hero-journey__availability">{{ identity.availability }}</p>
|
||||
<div class="hero-journey__actions">
|
||||
<a class="hero-journey__action hero-journey__action--primary" :href="`mailto:${email}`">Email Luca</a>
|
||||
<a class="hero-journey__action hero-journey__action--primary" href="#contact" @click="emit('contact', $event)">Contact Me</a>
|
||||
<a class="hero-journey__action" href="#selected-builds" @click="emit('explore', $event)">Explore work</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,6 @@ withDefaults(defineProps<{ project: Readonly<PublicProject>; allowAutomaticVideo
|
||||
: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>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@@ -13,6 +13,7 @@ export const MEDIA_ACTIVATION_MARGIN = '300px 0px 300px 0px';
|
||||
export function useLazyMedia(
|
||||
target: Readonly<Ref<Element | null>>,
|
||||
allowAutomatic: MaybeRef<boolean>,
|
||||
activationMargin = MEDIA_ACTIVATION_MARGIN,
|
||||
): LazyMediaState {
|
||||
const nearby = shallowRef(false);
|
||||
const explicitRequested = shallowRef(false);
|
||||
@@ -44,7 +45,7 @@ export function useLazyMedia(
|
||||
const inActivationRange = entries.some((entry) => entry.isIntersecting);
|
||||
nearby.value = inActivationRange;
|
||||
if (!inActivationRange) clearExplicit();
|
||||
}, { root, rootMargin: MEDIA_ACTIVATION_MARGIN, threshold: 0 });
|
||||
}, { root, rootMargin: activationMargin, threshold: 0 });
|
||||
observer.observe(element);
|
||||
};
|
||||
observe();
|
||||
|
||||
9
src/content/contact-email.ts
Normal file
9
src/content/contact-email.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ContactContent } from './types';
|
||||
|
||||
// The page carries separate parts; a complete address is assembled only when
|
||||
// someone interacts. This deters simple harvesting, not a determined crawler.
|
||||
export const contactAddress = (email: ContactContent['email']): string =>
|
||||
`${email.localPart}@${email.domain}`;
|
||||
|
||||
export const readableContactAddress = (email: ContactContent['email']): string =>
|
||||
`${email.localPart} [at] ${email.domain.replaceAll('.', ' [dot] ')}`;
|
||||
@@ -22,7 +22,6 @@ interface PersonJsonLd {
|
||||
jobTitle: string;
|
||||
description: string;
|
||||
url: string;
|
||||
email: string;
|
||||
homeLocation: PlaceJsonLd;
|
||||
sameAs?: string[];
|
||||
}
|
||||
@@ -104,7 +103,6 @@ export const createPageMetadata = (
|
||||
jobTitle: content.identity.role,
|
||||
description: content.metadata.description,
|
||||
url: site.canonicalUrl,
|
||||
email: `mailto:${content.contact.email}`,
|
||||
homeLocation: {
|
||||
'@type': 'Place',
|
||||
address: {
|
||||
|
||||
@@ -201,7 +201,12 @@ const portfolioContentSchema = z.object({
|
||||
}).strict()).min(1),
|
||||
contact: z.object({
|
||||
introduction: visibleString,
|
||||
email: z.email(),
|
||||
email: z.object({ localPart: visibleString, domain: visibleString }).strict().refine(
|
||||
({ localPart, domain }) => !localPart.includes('@') && !domain.includes('@') &&
|
||||
z.email().safeParse(`${localPart}@${domain}`).success,
|
||||
'Contact email parts must form a valid address.',
|
||||
),
|
||||
avatarAssetId: opaqueId.optional(),
|
||||
social: z.array(z.object({
|
||||
service: z.enum(['GitHub', 'GitLab', 'LinkedIn']),
|
||||
url: httpUrl,
|
||||
@@ -334,6 +339,7 @@ export const parseProductionInputSnapshot = (input: unknown): ProductionInputs =
|
||||
const { content, manifest, site } = productionInputsSchema.parse(input);
|
||||
const manifestIds = new Set(manifest.assets.map((asset) => asset.id));
|
||||
const referencedAssetIds = [
|
||||
...(content.contact.avatarAssetId === undefined ? [] : [content.contact.avatarAssetId]),
|
||||
...content.projects.selected.flatMap((project) => project.assetIds),
|
||||
...content.projects.additional.flatMap((project) => project.assetIds),
|
||||
...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) =>
|
||||
|
||||
@@ -133,7 +133,8 @@ export interface ContactSocialLink {
|
||||
|
||||
export interface ContactContent {
|
||||
introduction: string;
|
||||
email: string;
|
||||
email: { localPart: string; domain: string };
|
||||
avatarAssetId?: string;
|
||||
social: readonly ContactSocialLink[];
|
||||
cvMessage: string;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ import {
|
||||
* Keep this union exhaustive when a new public technology or social service is
|
||||
* introduced so an icon cannot silently regress to a placeholder.
|
||||
*/
|
||||
export type BrandIconLabel =
|
||||
import { conceptIcons } from './concept-icons';
|
||||
|
||||
export type BrandIconLabel = keyof typeof conceptIcons
|
||||
| 'Linux'
|
||||
| 'Docker'
|
||||
| 'Nginx'
|
||||
@@ -144,6 +146,7 @@ export const brandIcons: Readonly<Record<BrandIconLabel, BrandIconDefinition>> =
|
||||
GitHub: fromSimpleIcons(siGithub),
|
||||
GitLab: fromSimpleIcons(siGitlab),
|
||||
LinkedIn: linkedIn,
|
||||
...conceptIcons,
|
||||
} as const;
|
||||
|
||||
export const brandIconLabels = Object.freeze(Object.keys(brandIcons) as BrandIconLabel[]);
|
||||
|
||||
36
src/icons/concept-icons.ts
Normal file
36
src/icons/concept-icons.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Concept symbols from @phosphor-icons/vue 2.2.1, regular weight (MIT).
|
||||
// These describe a protocol or capability; they are not product logos.
|
||||
export const conceptIcons = {
|
||||
"DNS": {
|
||||
"title": "DNS",
|
||||
"hex": "AAB9CC",
|
||||
"path": "M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm88,104a87.62,87.62,0,0,1-6.4,32.94l-44.7-27.49a15.92,15.92,0,0,0-6.24-2.23l-22.82-3.08a16.11,16.11,0,0,0-16,7.86h-8.72l-3.8-7.86a15.91,15.91,0,0,0-11-8.67l-8-1.73L96.14,104h16.71a16.06,16.06,0,0,0,7.73-2l12.25-6.76a16.62,16.62,0,0,0,3-2.14l26.91-24.34A15.93,15.93,0,0,0,166,49.1l-.36-.65A88.11,88.11,0,0,1,216,128ZM143.31,41.34,152,56.9,125.09,81.24,112.85,88H96.14a16,16,0,0,0-13.88,8l-8.73,15.23L63.38,84.19,74.32,58.32a87.87,87.87,0,0,1,69-17ZM40,128a87.53,87.53,0,0,1,8.54-37.8l11.34,30.27a16,16,0,0,0,11.62,10l21.43,4.61L96.74,143a16.09,16.09,0,0,0,14.4,9h1.48l-7.23,16.23a16,16,0,0,0,2.86,17.37l.14.14L128,205.94l-1.94,10A88.11,88.11,0,0,1,40,128Zm102.58,86.78,1.13-5.81a16.09,16.09,0,0,0-4-13.9,1.85,1.85,0,0,1-.14-.14L120,174.74,133.7,144l22.82,3.08,45.72,28.12A88.18,88.18,0,0,1,142.58,214.78Z",
|
||||
"viewBox": "0 0 256 256",
|
||||
"source": "https://github.com/phosphor-icons/core/blob/main/assets/regular/globe-hemisphere-west.svg",
|
||||
"license": "MIT"
|
||||
},
|
||||
"TLS": {
|
||||
"title": "TLS",
|
||||
"hex": "AAB9CC",
|
||||
"path": "M128,112a28,28,0,0,0-8,54.83V184a8,8,0,0,0,16,0V166.83A28,28,0,0,0,128,112Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,128,152Zm80-72H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Z",
|
||||
"viewBox": "0 0 256 256",
|
||||
"source": "https://github.com/phosphor-icons/core/blob/main/assets/regular/lock-key.svg",
|
||||
"license": "MIT"
|
||||
},
|
||||
"Fail2Ban": {
|
||||
"title": "Fail2Ban",
|
||||
"hex": "AAB9CC",
|
||||
"path": "M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,25.26,47,25.53a8,8,0,0,0,4.2,0c1-.27,23.91-6.67,47-25.53C198.48,196.67,224,164.72,224,112V56A16,16,0,0,0,208,40Zm0,72c0,37.07-13.66,67.16-40.6,89.42A129.3,129.3,0,0,1,128,223.62a128.25,128.25,0,0,1-38.92-21.81C61.82,179.51,48,149.3,48,112l0-56,160,0ZM82.34,141.66a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32l-56,56a8,8,0,0,1-11.32,0Z",
|
||||
"viewBox": "0 0 256 256",
|
||||
"source": "https://github.com/phosphor-icons/core/blob/main/assets/regular/shield-check.svg",
|
||||
"license": "MIT"
|
||||
},
|
||||
"SQL": {
|
||||
"title": "SQL",
|
||||
"hex": "AAB9CC",
|
||||
"path": "M128,24C74.17,24,32,48.6,32,80v96c0,31.4,42.17,56,96,56s96-24.6,96-56V80C224,48.6,181.83,24,128,24Zm80,104c0,9.62-7.88,19.43-21.61,26.92C170.93,163.35,150.19,168,128,168s-42.93-4.65-58.39-13.08C55.88,147.43,48,137.62,48,128V111.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64ZM69.61,53.08C85.07,44.65,105.81,40,128,40s42.93,4.65,58.39,13.08C200.12,60.57,208,70.38,208,80s-7.88,19.43-21.61,26.92C170.93,115.35,150.19,120,128,120s-42.93-4.65-58.39-13.08C55.88,99.43,48,89.62,48,80S55.88,60.57,69.61,53.08ZM186.39,202.92C170.93,211.35,150.19,216,128,216s-42.93-4.65-58.39-13.08C55.88,195.43,48,185.62,48,176V159.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64V176C208,185.62,200.12,195.43,186.39,202.92Z",
|
||||
"viewBox": "0 0 256 256",
|
||||
"source": "https://github.com/phosphor-icons/core/blob/main/assets/regular/database.svg",
|
||||
"license": "MIT"
|
||||
}
|
||||
} as const;
|
||||
219
src/styles/refinements.css
Normal file
219
src/styles/refinements.css
Normal file
@@ -0,0 +1,219 @@
|
||||
/* Contact follows the same compact reading card as the rest of the journey. */
|
||||
#contact {
|
||||
position: relative;
|
||||
}
|
||||
#contact > h2 {
|
||||
padding-inline-end: 110px;
|
||||
}
|
||||
.contact-avatar {
|
||||
position: absolute;
|
||||
inset-block-start: 76px;
|
||||
inset-inline-end: clamp(28px, 4vw, 58px);
|
||||
inline-size: 88px;
|
||||
block-size: 96px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 5px 12px rgb(0 0 0 / 20%));
|
||||
}
|
||||
.contact-destination__copy {
|
||||
font-size: clamp(23px, 1.95vw, 29px);
|
||||
line-height: 1.3;
|
||||
max-inline-size: 34ch;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.contact-email {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
column-gap: 12px;
|
||||
}
|
||||
.contact-email__action {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.contact-email .contact-link {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.contact-email__preview {
|
||||
position: absolute;
|
||||
inset-inline-start: 116px;
|
||||
white-space: nowrap;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateX(-24px);
|
||||
transition: opacity 200ms ease, transform 260ms ease, visibility 260ms;
|
||||
}
|
||||
@media (hover: hover) and (min-width: 768px) {
|
||||
.contact-email__action:is(:hover, :focus-within) .contact-email__preview {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.contact-email__details {
|
||||
display: contents;
|
||||
}
|
||||
.contact-email__details > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-block-size: 44px;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
list-style: none;
|
||||
}
|
||||
.contact-email__details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.contact-email__details:not([open]) > .contact-email__disclosure {
|
||||
display: none;
|
||||
}
|
||||
.contact-email__disclosure {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 14px;
|
||||
padding-block-start: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.contact-email__address {
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
.contact-email__copy {
|
||||
min-block-size: 44px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.contact-email__status {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.contact-email[data-enhanced='false'] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.contact-email[data-enhanced='false'] .contact-email__action {
|
||||
display: none;
|
||||
}
|
||||
.contact-email[data-enhanced='false'] summary {
|
||||
inline-size: fit-content;
|
||||
padding-inline: 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--cyan);
|
||||
color: #201031;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
.portfolio-section .contact-social > li {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.contact-social {
|
||||
align-items: center;
|
||||
gap: 12px 20px;
|
||||
margin-block-end: 0;
|
||||
padding-block-start: 16px !important;
|
||||
}
|
||||
.contact-social-link {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.project-card .project-media-gallery {
|
||||
margin-block: 20px;
|
||||
}
|
||||
.project-card .project-media {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.project-media--video video {
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
block-size: auto;
|
||||
}
|
||||
.video-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.project-media--video[data-enhanced='false'] .video-controls {
|
||||
display: none;
|
||||
}
|
||||
.video-controls > p {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.video-fallback {
|
||||
display: block;
|
||||
margin-block-start: 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.contact-avatar {
|
||||
inset-block-start: 48px;
|
||||
inline-size: 64px;
|
||||
block-size: 70px;
|
||||
}
|
||||
#contact > h2 {
|
||||
padding-inline-end: 76px;
|
||||
}
|
||||
.contact-email__preview {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
#contact > h2 {
|
||||
padding-inline-end: 76px;
|
||||
}
|
||||
.contact-avatar {
|
||||
inset-block-start: 32px;
|
||||
inset-inline-end: 22px;
|
||||
inline-size: 64px;
|
||||
block-size: 70px;
|
||||
}
|
||||
.contact-destination__copy {
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.contact-social {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) and (max-height: 559px) {
|
||||
.contact-avatar {
|
||||
inset-block-start: 42px;
|
||||
inset-inline-end: 24px;
|
||||
inline-size: 64px;
|
||||
block-size: 70px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.contact-email__preview {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
.contact-avatar {
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 60px;
|
||||
block-size: 65px;
|
||||
}
|
||||
.contact-email__preview,
|
||||
.contact-email__copy,
|
||||
.contact-email__status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user