feat: one plate rule for every card, with scroll-linked entrances

Panels, records and grid groups share .plate: a registration plate, a
lit accent edge, focus that reads like hover, and a 28 px rise with a
top-down unmask on view() entry, or an IntersectionObserver fallback.
This commit is contained in:
2026-10-10 23:30:58 +03:00
parent 9b79e031ff
commit 5757dd968f
6 changed files with 111 additions and 73 deletions

View File

@@ -10,7 +10,7 @@ defineProps<{
<template> <template>
<ol class="experience-timeline"> <ol class="experience-timeline">
<li v-for="record in experience" :key="record.id"> <li v-for="record in experience" :key="record.id">
<article class="experience-record"> <article class="experience-record plate plate--record">
<h3>{{ record.role }}</h3> <h3>{{ record.role }}</h3>
<p class="experience-record__meta">{{ record.organization }} · {{ record.dates }}<template v-if="record.location"> · {{ record.location }}</template></p> <p class="experience-record__meta">{{ record.organization }} · {{ record.dates }}<template v-if="record.location"> · {{ record.location }}</template></p>
<ul> <ul>
@@ -20,7 +20,7 @@ defineProps<{
</li> </li>
</ol> </ol>
<article class="systems-operations"> <article class="systems-operations plate plate--panel">
<h3>{{ systemsOperations.title }}</h3> <h3>{{ systemsOperations.title }}</h3>
<p>{{ systemsOperations.summary }}</p> <p>{{ systemsOperations.summary }}</p>
<ul> <ul>

View File

@@ -6,7 +6,7 @@ defineProps<{ infrastructure: Readonly<InfrastructureCaseStudy> }>();
</script> </script>
<template> <template>
<article class="infrastructure-case"> <article class="infrastructure-case plate plate--panel">
<h3 :id="`${infrastructure.id}-diagram-title`"> <h3 :id="`${infrastructure.id}-diagram-title`">
{{ infrastructure.title }} {{ infrastructure.title }}
</h3> </h3>

View File

@@ -16,7 +16,7 @@ withDefaults(defineProps<{
<template> <template>
<article <article
class="project-card" class="project-card plate plate--panel"
:data-project-id="project.id" :data-project-id="project.id"
:data-maturity="project.maturity" :data-maturity="project.maturity"
:data-media-layout="project.mediaLayout ?? 'none'" :data-media-layout="project.mediaLayout ?? 'none'"

View File

@@ -8,7 +8,7 @@ defineProps<{ groups: readonly ToolkitGroup[] }>();
<template> <template>
<div class="toolkit-grid"> <div class="toolkit-grid">
<section v-for="group in groups" :key="group.title" class="toolkit-group" :aria-labelledby="`${group.title}-heading`"> <section v-for="group in groups" :key="group.title" class="toolkit-group plate plate--grid" :aria-labelledby="`${group.title}-heading`">
<h3 :id="`${group.title}-heading`">{{ group.title }}</h3> <h3 :id="`${group.title}-heading`">{{ group.title }}</h3>
<ul class="toolkit-items"> <ul class="toolkit-items">
<li v-for="technology in group.technologies" :key="technology.name" class="toolkit-item" data-testid="toolkit-technology"> <li v-for="technology in group.technologies" :key="technology.name" class="toolkit-item" data-testid="toolkit-technology">

View File

@@ -0,0 +1,31 @@
import { onMounted, onUnmounted } from 'vue';
/** Card entrances for browsers without scroll-driven animations (Firefox):
* each .plate gets data-entered once it reaches the reading window, and
* cards.css transitions it in. Browsers with view() timelines need nothing
* here, and reduced motion leaves cards still. */
export function useCardEntrances(): void {
let observer: IntersectionObserver | undefined;
let stage: HTMLElement | null = null;
onMounted(() => {
if (typeof IntersectionObserver === 'undefined') return;
if (CSS.supports?.('animation-timeline: view()')) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
stage = document.querySelector<HTMLElement>('.reading-stage');
if (!stage) return;
const cards = stage.querySelectorAll<HTMLElement>('.plate, .infrastructure-node');
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.setAttribute('data-entered', '');
observer?.unobserve(entry.target);
}
}, { rootMargin: '0px 0px -12% 0px' });
cards.forEach((card) => observer?.observe(card));
stage.setAttribute('data-entrances', '');
});
onUnmounted(() => {
observer?.disconnect();
stage?.removeAttribute('data-entrances');
});
}

View File

@@ -1,27 +1,42 @@
/* ========================================================================== /* ==========================================================================
Round 5: one card rule, and the motion pass. Round 5: one card rule, and the motion pass.
The rule: a chapter title sits outside, above its plate. Every card title The rule: a chapter title sits outside, above its plate. Every card is a
sits inside its card, first, under a mono registration plate in the .plate and its title sits inside it, first, under a mono registration
form "EXIT.N / KIND", the same plate the project cards already carry. plate in the form "EXIT.N / KIND", the same plate the project cards
Plates are decorative and generated by CSS (alt text ''), so the server already carry. Three variants:
and hydrated text stay identical and screen readers hear titles only. .plate--panel standalone cards: a lit top edge that runs on hover
.plate--record timeline entries: they step along the route on hover
.plate--grid toolkit groups: the same step, in a grid
Registration plates are decorative and generated by CSS (alt text ''),
so the server and hydrated text stay identical and screen readers hear
titles only.
Motion is transform, clip-path and colour only, never on the chapter Motion tokens: arrivals ease out-expo, departures ease in-quart, colour
headings (journey and audio measure them), and only with eases. 160 ms micro, 320 ms UI, 720 ms entrance, 900-1400 ms cinematic.
Transform, clip-path and colour only, never on the chapter headings
(journey and audio measure them), and only with
prefers-reduced-motion: no-preference. prefers-reduced-motion: no-preference.
========================================================================== */ ========================================================================== */
:root {
--ease-arrive: cubic-bezier(0.22, 1, 0.36, 1);
--ease-depart: cubic-bezier(0.5, 0, 0.75, 0);
--dur-micro: 160ms;
--dur-ui: 320ms;
--dur-entrance: 720ms;
}
/* ---------- Registration plates for the cards that lacked one ---------- */ /* ---------- Registration plates for the cards that lacked one ---------- */
.experience-timeline { counter-reset: role; } .experience-timeline { counter-reset: role; }
.experience-timeline > li { counter-increment: role; } .experience-timeline > li { counter-increment: role; }
.toolkit-grid { counter-reset: stack; } .toolkit-grid { counter-reset: stack; }
.toolkit-group { counter-increment: stack; } .toolkit-group { counter-increment: stack; }
.experience-record h3::before, .plate--record h3::before,
.systems-operations h3::before, .systems-operations h3::before,
.infrastructure-case > h3::before, .infrastructure-case > h3::before,
.toolkit-group h3::before { .plate--grid h3::before {
display: block; display: block;
inline-size: max-content; inline-size: max-content;
max-inline-size: 100%; max-inline-size: 100%;
@@ -34,36 +49,29 @@
letter-spacing: 0.14em; letter-spacing: 0.14em;
text-transform: uppercase; text-transform: uppercase;
} }
.experience-record h3::before { content: '03.' counter(role) ' / Role' / ''; } .plate--record h3::before { content: '03.' counter(role) ' / Role' / ''; }
.systems-operations h3::before { content: '03.S / Operations' / ''; } .systems-operations h3::before { content: '03.S / Operations' / ''; }
.infrastructure-case > h3::before { content: '04.1 / Platform' / ''; } .infrastructure-case > h3::before { content: '04.1 / Platform' / ''; }
.toolkit-group h3::before { content: '07.' counter(stack) ' / Stack' / ''; } .plate--grid h3::before { content: '07.' counter(stack) ' / Stack' / ''; }
@media (forced-colors: active) { @media (forced-colors: active) {
.experience-record h3::before, .plate h3::before {
.systems-operations h3::before,
.infrastructure-case > h3::before,
.toolkit-group h3::before {
border-color: CanvasText; border-color: CanvasText;
color: CanvasText; color: CanvasText;
} }
} }
@media print { @media print {
.experience-record h3::before, .plate--record h3::before,
.systems-operations h3::before, .systems-operations h3::before,
.infrastructure-case > h3::before, .infrastructure-case > h3::before,
.toolkit-group h3::before { display: none; } .plate--grid h3::before { display: none; }
} }
/* ---------- Tailored cards ---------- */ /* ---------- Panels: a lit top edge in the chapter accent ---------- */
/* Project cards and the operations card are panels: a lit top edge in the .plate {
chapter accent, which the hover draws out across the card. */
.project-card,
.systems-operations {
position: relative; position: relative;
} }
.project-card::after, .plate--panel::after {
.systems-operations::after {
content: '' / ''; content: '' / '';
position: absolute; position: absolute;
inset: 0 0 auto; inset: 0 0 auto;
@@ -77,70 +85,73 @@
transform: scaleX(0.34); transform: scaleX(0.34);
transform-origin: left; transform-origin: left;
} }
/* Keyboard focus inside a card reads the same as hover, plus an outline. */
.plate:has(:focus-visible) {
outline: 2px solid var(--focus);
outline-offset: 4px;
}
.plate--panel:is(:hover, :has(:focus-visible))::after {
transform: scaleX(1);
}
@media (forced-colors: active) { @media (forced-colors: active) {
.project-card::after, .plate--panel::after { display: none; }
.systems-operations::after { display: none; } .plate:has(:focus-visible) { outline-color: Highlight; }
} }
/* ---------- Motion ---------- */ /* ---------- Motion ---------- */
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
/* Hover and focus: cards lift, their accent edge runs across. */ .plate {
.project-card,
.systems-operations,
.experience-record,
.toolkit-group {
transition: transition:
transform 420ms cubic-bezier(0.22, 1, 0.36, 1), transform var(--dur-ui) var(--ease-arrive),
box-shadow 420ms ease; box-shadow var(--dur-ui) ease;
} }
.project-card::after, .plate--panel::after {
.systems-operations::after { transition: transform var(--dur-entrance) var(--ease-arrive);
transition: transform 620ms cubic-bezier(0.22, 1, 0.36, 1);
} }
.project-card:is(:hover, :focus-within)::after, .plate--panel:is(:hover, :has(:focus-visible)) {
.systems-operations:is(:hover, :focus-within)::after {
transform: scaleX(1);
}
.project-card:is(:hover, :focus-within),
.systems-operations:is(:hover, :focus-within) {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 22px 48px -24px color-mix(in srgb, var(--chapter-accent, var(--accent)) 55%, transparent); box-shadow: 0 22px 48px -24px color-mix(in srgb, var(--chapter-accent, var(--accent)) 55%, transparent);
} }
.experience-record:is(:hover, :focus-within), .plate:is(.plate--record, .plate--grid):is(:hover, :has(:focus-visible)) {
.toolkit-group:is(:hover, :focus-within) {
transform: translateX(6px); transform: translateX(6px);
} }
.toolkit-item svg, .toolkit-item svg,
.toolkit-item img { .toolkit-item img {
transition: transform 320ms cubic-bezier(0.22, 1, 0.36, 1); transition: transform var(--dur-micro) var(--ease-arrive);
} }
.toolkit-item:hover svg, .toolkit-item:hover svg,
.toolkit-item:hover img { .toolkit-item:hover img {
transform: translateY(-2px) scale(1.12); transform: translateY(-2px) scale(1.12);
} }
/* Card reveals: each card rises and unmasks as it enters the reading /* Entrances animate translate, so the hover transform still composes.
window. Scroll-linked, so it can never lag behind the reader. */ Each plate rises 28 px and unmasks top-down as it enters
the reading window, scroll-linked so it can never lag the reader.
Browsers without view timelines get the same entrance from an
IntersectionObserver class (useCardEntrances). */
@supports (animation-timeline: view()) { @supports (animation-timeline: view()) {
.reading-stage .project-card, .reading-stage .plate,
.reading-stage .systems-operations,
.reading-stage .experience-record,
.reading-stage .toolkit-group,
.reading-stage .infrastructure-node { .reading-stage .infrastructure-node {
animation: card-reveal linear both; animation: plate-entrance linear both;
animation-timeline: view(); animation-timeline: view();
animation-range: entry 0% entry 55%; animation-range: entry 0% entry 55%;
} }
} }
@supports not (animation-timeline: view()) {
/* Chapter transitions: the arriving chapter's plate sweeps a light edge .reading-stage[data-entrances] .plate:not([data-entered]),
across and its first cards settle in sequence. */ .reading-stage[data-entrances] .infrastructure-node:not([data-entered]) {
.reading-stage .portfolio-section[data-current] .chapter-body > :is(p, dl, ul, .experience-timeline, .toolkit-grid) { translate: 0 28px;
animation: chapter-settle 900ms cubic-bezier(0.16, 1, 0.3, 1) both; clip-path: inset(0 0 100% 0);
}
.reading-stage[data-entrances] .plate[data-entered],
.reading-stage[data-entrances] .infrastructure-node[data-entered] {
transition:
translate var(--dur-entrance) var(--ease-arrive),
clip-path var(--dur-entrance) var(--ease-arrive),
transform var(--dur-ui) var(--ease-arrive),
box-shadow var(--dur-ui) ease;
}
} }
.reading-stage .portfolio-section[data-current] .chapter-body > :nth-child(2) { animation-delay: 90ms; }
.reading-stage .portfolio-section[data-current] .chapter-body > :nth-child(3) { animation-delay: 180ms; }
.reading-stage .portfolio-section[data-current] .chapter-body > :nth-child(n + 4) { animation-delay: 270ms; }
/* The gantry's lane lamps light in sequence along the route. */ /* The gantry's lane lamps light in sequence along the route. */
html .site-navigation li:nth-child(2)::after { transition-delay: 40ms; } html .site-navigation li:nth-child(2)::after { transition-delay: 40ms; }
@@ -152,17 +163,13 @@
html .site-navigation li:nth-child(8)::after { transition-delay: 280ms; } html .site-navigation li:nth-child(8)::after { transition-delay: 280ms; }
} }
@keyframes card-reveal { @keyframes plate-entrance {
from { from {
transform: translateY(36px); translate: 0 28px;
clip-path: inset(0 0 22% 0); clip-path: inset(0 0 100% 0);
} }
to { to {
transform: none; translate: none;
clip-path: inset(-40px -40px -40px -40px); clip-path: inset(-40px -40px -40px -40px);
} }
} }
@keyframes chapter-settle {
from { transform: translateY(14px); }
to { transform: none; }
}