Files
Portfolio/src/styles/cards.css
LucaP 5757dd968f 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.
2026-10-10 23:30:58 +03:00

176 lines
6.2 KiB
CSS

/* ==========================================================================
Round 5: one card rule, and the motion pass.
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 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; }
.plate--record h3::before,
.systems-operations h3::before,
.infrastructure-case > h3::before,
.plate--grid h3::before {
display: block;
inline-size: max-content;
max-inline-size: 100%;
margin-block-end: 0.85rem;
padding: 0.34rem 0.6rem 0.3rem;
border: 1px solid color-mix(in srgb, var(--chapter-accent, var(--accent)) 55%, transparent);
border-radius: 3px;
color: color-mix(in srgb, var(--chapter-accent, var(--accent)) 72%, var(--text));
font: 650 0.64rem/1.2 var(--mono);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.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' / ''; }
.plate--grid h3::before { content: '07.' counter(stack) ' / Stack' / ''; }
@media (forced-colors: active) {
.plate h3::before {
border-color: CanvasText;
color: CanvasText;
}
}
@media print {
.plate--record h3::before,
.systems-operations h3::before,
.infrastructure-case > h3::before,
.plate--grid h3::before { display: none; }
}
/* ---------- Panels: a lit top edge in the chapter accent ---------- */
.plate {
position: relative;
}
.plate--panel::after {
content: '' / '';
position: absolute;
inset: 0 0 auto;
block-size: 2px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--chapter-accent, var(--accent)) 85%, transparent),
transparent
);
pointer-events: none;
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) {
.plate--panel::after { display: none; }
.plate:has(:focus-visible) { outline-color: Highlight; }
}
/* ---------- Motion ---------- */
@media (prefers-reduced-motion: no-preference) {
.plate {
transition:
transform var(--dur-ui) var(--ease-arrive),
box-shadow var(--dur-ui) ease;
}
.plate--panel::after {
transition: transform var(--dur-entrance) var(--ease-arrive);
}
.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);
}
.plate:is(.plate--record, .plate--grid):is(:hover, :has(:focus-visible)) {
transform: translateX(6px);
}
.toolkit-item svg,
.toolkit-item img {
transition: transform var(--dur-micro) var(--ease-arrive);
}
.toolkit-item:hover svg,
.toolkit-item:hover img {
transform: translateY(-2px) scale(1.12);
}
/* 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 .plate,
.reading-stage .infrastructure-node {
animation: plate-entrance linear both;
animation-timeline: view();
animation-range: entry 0% entry 55%;
}
}
@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;
}
}
/* 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(3)::after { transition-delay: 80ms; }
html .site-navigation li:nth-child(4)::after { transition-delay: 120ms; }
html .site-navigation li:nth-child(5)::after { transition-delay: 160ms; }
html .site-navigation li:nth-child(6)::after { transition-delay: 200ms; }
html .site-navigation li:nth-child(7)::after { transition-delay: 240ms; }
html .site-navigation li:nth-child(8)::after { transition-delay: 280ms; }
}
@keyframes plate-entrance {
from {
translate: 0 28px;
clip-path: inset(0 0 100% 0);
}
to {
translate: none;
clip-path: inset(-40px -40px -40px -40px);
}
}