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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ defineProps<{ groups: readonly ToolkitGroup[] }>();
<template>
<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>
<ul class="toolkit-items">
<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.
The rule: a chapter title sits outside, above its plate. Every card title
sits inside its card, first, under a mono registration plate in the
form "EXIT.N / KIND", the same plate the project cards already carry.
Plates are decorative and generated by CSS (alt text ''), so the server
and hydrated text stay identical and screen readers hear titles only.
The rule: a chapter title sits outside, above its plate. Every card is a
.plate and its title sits inside it, first, under a mono registration
plate in the form "EXIT.N / KIND", the same plate the project cards
already carry. Three variants:
.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
headings (journey and audio measure them), and only with
Motion tokens: arrivals ease out-expo, departures ease in-quart, colour
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.
========================================================================== */
: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 ---------- */
.experience-timeline { counter-reset: role; }
.experience-timeline > li { counter-increment: role; }
.toolkit-grid { counter-reset: stack; }
.toolkit-group { counter-increment: stack; }
.experience-record h3::before,
.plate--record h3::before,
.systems-operations h3::before,
.infrastructure-case > h3::before,
.toolkit-group h3::before {
.plate--grid h3::before {
display: block;
inline-size: max-content;
max-inline-size: 100%;
@@ -34,36 +49,29 @@
letter-spacing: 0.14em;
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' / ''; }
.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) {
.experience-record h3::before,
.systems-operations h3::before,
.infrastructure-case > h3::before,
.toolkit-group h3::before {
.plate h3::before {
border-color: CanvasText;
color: CanvasText;
}
}
@media print {
.experience-record h3::before,
.plate--record h3::before,
.systems-operations h3::before,
.infrastructure-case > h3::before,
.toolkit-group h3::before { display: none; }
.plate--grid h3::before { display: none; }
}
/* ---------- Tailored cards ---------- */
/* Project cards and the operations card are panels: a lit top edge in the
chapter accent, which the hover draws out across the card. */
.project-card,
.systems-operations {
/* ---------- Panels: a lit top edge in the chapter accent ---------- */
.plate {
position: relative;
}
.project-card::after,
.systems-operations::after {
.plate--panel::after {
content: '' / '';
position: absolute;
inset: 0 0 auto;
@@ -77,70 +85,73 @@
transform: scaleX(0.34);
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) {
.project-card::after,
.systems-operations::after { display: none; }
.plate--panel::after { display: none; }
.plate:has(:focus-visible) { outline-color: Highlight; }
}
/* ---------- Motion ---------- */
@media (prefers-reduced-motion: no-preference) {
/* Hover and focus: cards lift, their accent edge runs across. */
.project-card,
.systems-operations,
.experience-record,
.toolkit-group {
.plate {
transition:
transform 420ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 420ms ease;
transform var(--dur-ui) var(--ease-arrive),
box-shadow var(--dur-ui) ease;
}
.project-card::after,
.systems-operations::after {
transition: transform 620ms cubic-bezier(0.22, 1, 0.36, 1);
.plate--panel::after {
transition: transform var(--dur-entrance) var(--ease-arrive);
}
.project-card:is(:hover, :focus-within)::after,
.systems-operations:is(:hover, :focus-within)::after {
transform: scaleX(1);
}
.project-card:is(:hover, :focus-within),
.systems-operations:is(:hover, :focus-within) {
.plate--panel:is(:hover, :has(:focus-visible)) {
transform: translateY(-4px);
box-shadow: 0 22px 48px -24px color-mix(in srgb, var(--chapter-accent, var(--accent)) 55%, transparent);
}
.experience-record:is(:hover, :focus-within),
.toolkit-group:is(:hover, :focus-within) {
.plate:is(.plate--record, .plate--grid):is(:hover, :has(:focus-visible)) {
transform: translateX(6px);
}
.toolkit-item svg,
.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 img {
transform: translateY(-2px) scale(1.12);
}
/* Card reveals: each card rises and unmasks as it enters the reading
window. Scroll-linked, so it can never lag behind the reader. */
/* Entrances animate translate, so the hover transform still composes.
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()) {
.reading-stage .project-card,
.reading-stage .systems-operations,
.reading-stage .experience-record,
.reading-stage .toolkit-group,
.reading-stage .plate,
.reading-stage .infrastructure-node {
animation: card-reveal linear both;
animation: plate-entrance linear both;
animation-timeline: view();
animation-range: entry 0% entry 55%;
}
}
/* Chapter transitions: the arriving chapter's plate sweeps a light edge
across and its first cards settle in sequence. */
.reading-stage .portfolio-section[data-current] .chapter-body > :is(p, dl, ul, .experience-timeline, .toolkit-grid) {
animation: chapter-settle 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
@supports not (animation-timeline: view()) {
.reading-stage[data-entrances] .plate:not([data-entered]),
.reading-stage[data-entrances] .infrastructure-node:not([data-entered]) {
translate: 0 28px;
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. */
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; }
}
@keyframes card-reveal {
@keyframes plate-entrance {
from {
transform: translateY(36px);
clip-path: inset(0 0 22% 0);
translate: 0 28px;
clip-path: inset(0 0 100% 0);
}
to {
transform: none;
translate: none;
clip-path: inset(-40px -40px -40px -40px);
}
}
@keyframes chapter-settle {
from { transform: translateY(14px); }
to { transform: none; }
}