ui polish pass: scroll-driven effects (card reveal, header shadow, reading progress), nav underline-grow, card meta icons, og:image svg fallback, apple-touch-icon+favicon, hero 472->180KB + preload, article cover CLS guard, ::selection, per-article lang, robots noindex (demo); article: db-cms comparison diagram, 6-year pros/cons, reuse section
This commit is contained in:
@@ -120,7 +120,18 @@ ctao.org drifts looping nebula videos under Galaxy vignettes; our translation:
|
||||
(Cherenkov .16 / Azure .12 / Indigo .10) drifting on a 36s alternate
|
||||
cycle. Opt-in via `<Base ambient>`: **home only**. Body text NEVER sits on
|
||||
a moving gradient — cards, panels and bands are solid layers above it.
|
||||
4. One-shot hero `rise` stagger; unified hover system (below). Nothing else.
|
||||
4. One-shot hero `rise` stagger; unified hover system (below).
|
||||
5. **CSS scroll-driven effects** (2026 pass; `animation-timeline`, no JS —
|
||||
"no scroll-triggered motion" below means no JS/observer motion): card
|
||||
reveal on viewport entry (`view()`, entry 0–35%), header shadow fading in
|
||||
over the first 80px of scroll, article reading-progress bar (2px Cherenkov
|
||||
under the header — functional navigation state, same budget line as the
|
||||
TOC scrollspy). All behind `@supports (animation-timeline: view())`,
|
||||
compositor-only (the header shadow animates a pseudo-element's OPACITY,
|
||||
never box-shadow in keyframes), auto-killed by the reduce block.
|
||||
6. Micro-interactions: nav underline-grow (2px white rule, `scaleX` 0→1 on
|
||||
hover, held on `aria-current`) and 1px press-down on `.btn`/navlink
|
||||
`:active`. Nothing else.
|
||||
|
||||
**One hover language** (mid-pass audit: hovers were inconsistent per element;
|
||||
ctao.org uses a single quiet-fade token — we do the same): every interactive
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 461 KiB After Width: | Height: | Size: 176 KiB |
@@ -0,0 +1,110 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 640" role="img" aria-label="Architektura klasycznego CMS z bazą danych: aplikacja CMS z publicznym panelem admina działa non stop, obok baza danych i wolumen mediów; czytelnik dostaje strony renderowane na żądanie">
|
||||
<defs>
|
||||
<marker id="arrB" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0 0 L10 5 L0 10 z" fill="#00004a"/>
|
||||
</marker>
|
||||
<filter id="softB" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="5" flood-color="#00004a" flood-opacity="0.10"/>
|
||||
</filter>
|
||||
<style>
|
||||
.t { font-family: Inter, "Segoe UI", Arial, sans-serif; }
|
||||
.box { fill: #ffffff; stroke: #00004a; stroke-width: 1.5; }
|
||||
.sub { fill: #f5f5f5; stroke: #e2e5ee; stroke-width: 1; }
|
||||
.ttl { font-weight: 700; font-size: 15.5px; fill: #00004a; letter-spacing: .04em; }
|
||||
.cap { font-size: 12.5px; fill: #46536a; }
|
||||
.lbl { font-size: 12px; fill: #46536a; paint-order: stroke; stroke: #ffffff; stroke-width: 4; }
|
||||
.chipbg { fill: #e9ebf3; }
|
||||
.chip { font-size: 11.5px; font-weight: 700; fill: #00004a; letter-spacing: .06em; }
|
||||
.flow { stroke: #00004a; stroke-width: 1.8; fill: none; marker-end: url(#arrB); }
|
||||
.pill { fill: #f5f5f5; stroke: #e2e5ee; }
|
||||
.pilltxt { font-size: 12.5px; fill: #101228; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- machine boundary (mirrors the static-demo diagram 1:1) -->
|
||||
<rect x="340" y="40" width="830" height="570" rx="18" fill="#f8f9fc" stroke="#e2e5ee" stroke-width="1.5"/>
|
||||
<text class="t" x="366" y="76" font-size="13" font-weight="700" fill="#00004a" letter-spacing=".18em">MASZYNA · CMS Z BAZĄ DANYCH</text>
|
||||
<text class="t cap" x="366" y="96">np. WordPress, Strapi, Drupal — wspólny schemat tej klasy rozwiązań</text>
|
||||
|
||||
<!-- actor: redaktor -->
|
||||
<g filter="url(#softB)">
|
||||
<rect class="box" x="52" y="120" width="220" height="120" rx="16"/>
|
||||
</g>
|
||||
<circle cx="88" cy="156" r="11" fill="none" stroke="#00004a" stroke-width="1.8"/>
|
||||
<path d="M72 186 q16 -18 32 0" fill="none" stroke="#00004a" stroke-width="1.8"/>
|
||||
<text class="t ttl" x="116" y="162">Redaktor</text>
|
||||
<text class="t cap" x="70" y="212">panel admina w przeglądarce</text>
|
||||
<text class="t cap" x="70" y="228">logowanie do aplikacji</text>
|
||||
|
||||
<!-- actor: czytelnik -->
|
||||
<g filter="url(#softB)">
|
||||
<rect class="box" x="52" y="400" width="220" height="120" rx="16"/>
|
||||
</g>
|
||||
<circle cx="88" cy="436" r="11" fill="none" stroke="#00004a" stroke-width="1.8"/>
|
||||
<path d="M72 466 q16 -18 32 0" fill="none" stroke="#00004a" stroke-width="1.8"/>
|
||||
<text class="t ttl" x="116" y="442">Czytelnik</text>
|
||||
<text class="t cap" x="70" y="492">publiczna strona portalu</text>
|
||||
<text class="t cap" x="70" y="508">HTML składany na żądanie</text>
|
||||
|
||||
<!-- CMS application (tall box, always running) -->
|
||||
<g filter="url(#softB)">
|
||||
<rect class="box" x="400" y="120" width="320" height="400" rx="16"/>
|
||||
</g>
|
||||
<text class="t ttl" x="424" y="152">APLIKACJA CMS</text>
|
||||
<rect class="chipbg" x="590" y="136" width="106" height="22" rx="11"/>
|
||||
<text class="t chip" x="643" y="151" text-anchor="middle">NON STOP</text>
|
||||
<rect class="sub" x="420" y="176" width="280" height="88" rx="12"/>
|
||||
<text class="t" x="436" y="202" font-size="13.5" font-weight="700" fill="#101228">panel admina</text>
|
||||
<text class="t cap" x="436" y="224">publiczny adres + logowanie</text>
|
||||
<text class="t cap" x="436" y="242">z internetu</text>
|
||||
<rect class="sub" x="420" y="278" width="280" height="88" rx="12"/>
|
||||
<text class="t" x="436" y="304" font-size="13.5" font-weight="700" fill="#101228">renderowanie stron</text>
|
||||
<text class="t cap" x="436" y="326">każde wejście czytelnika =</text>
|
||||
<text class="t cap" x="436" y="344">praca aplikacji i bazy</text>
|
||||
<rect class="sub" x="420" y="380" width="280" height="88" rx="12"/>
|
||||
<text class="t" x="436" y="406" font-size="13.5" font-weight="700" fill="#101228">pluginy / rozszerzenia</text>
|
||||
<text class="t cap" x="436" y="428">własny cykl aktualizacji</text>
|
||||
<text class="t cap" x="436" y="446">i podatności</text>
|
||||
|
||||
<!-- database -->
|
||||
<g filter="url(#softB)">
|
||||
<rect class="box" x="790" y="120" width="340" height="185" rx="16"/>
|
||||
</g>
|
||||
<text class="t ttl" x="814" y="152">BAZA DANYCH</text>
|
||||
<rect class="chipbg" x="972" y="136" width="106" height="22" rx="11"/>
|
||||
<text class="t chip" x="1025" y="151" text-anchor="middle">NON STOP</text>
|
||||
<text class="t cap" x="814" y="188">treść + konta użytkowników</text>
|
||||
<text class="t cap" x="814" y="212">wymaga backupów i testów odtwarzania</text>
|
||||
<text class="t cap" x="814" y="236">migracje schematu przy aktualizacjach</text>
|
||||
<text class="t cap" x="814" y="266">historia zmian: zależnie od produktu,</text>
|
||||
<text class="t cap" x="814" y="284">bywa funkcją płatną</text>
|
||||
|
||||
<!-- media volume -->
|
||||
<g filter="url(#softB)">
|
||||
<rect class="sub" x="790" y="400" width="340" height="120" rx="16"/>
|
||||
</g>
|
||||
<text class="t ttl" x="814" y="432">media/</text>
|
||||
<rect class="chipbg" x="896" y="416" width="82" height="22" rx="11"/>
|
||||
<text class="t chip" x="937" y="431" text-anchor="middle">WOLUMEN</text>
|
||||
<text class="t cap" x="814" y="462">obrazy i pliki poza bazą</text>
|
||||
<text class="t cap" x="814" y="480">osobny backup</text>
|
||||
|
||||
<!-- flows -->
|
||||
<path class="flow" d="M272 180 H396"/>
|
||||
<text class="t lbl" x="334" y="170" text-anchor="middle">logowanie do panelu</text>
|
||||
|
||||
<path class="flow" d="M400 460 H276"/>
|
||||
<text class="t lbl" x="338" y="450" text-anchor="middle">HTML na żądanie</text>
|
||||
|
||||
<path class="flow" d="M720 212 H786"/>
|
||||
<text class="t lbl" x="753" y="202" text-anchor="middle">SQL</text>
|
||||
|
||||
<path class="flow" d="M720 440 H786"/>
|
||||
<text class="t lbl" x="753" y="430" text-anchor="middle">upload</text>
|
||||
|
||||
<!-- summary pills -->
|
||||
<rect class="pill" x="380" y="552" width="360" height="36" rx="18"/>
|
||||
<text class="t pilltxt" x="560" y="575" text-anchor="middle">2 procesy działają bez przerwy, także nocą</text>
|
||||
<rect class="pill" x="770" y="552" width="360" height="36" rx="18"/>
|
||||
<text class="t pilltxt" x="950" y="575" text-anchor="middle">aktualizacje bezpieczeństwa = stały obowiązek</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 955 B |
+3
-4
@@ -1,5 +1,4 @@
|
||||
# Demo instance — keep out of search indexes until this becomes the real
|
||||
# portal on its final domain (then: Allow: / and restore the Sitemap line).
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /admin/
|
||||
|
||||
Sitemap: https://portal.ctao.org/sitemap.xml
|
||||
Disallow: /
|
||||
|
||||
@@ -15,6 +15,9 @@ const news = defineCollection({
|
||||
// Public-path string, e.g. "/uploads/foo.jpg" — Sveltia uploads media to
|
||||
// public/uploads, which Astro's image() helper can't validate (src/ only).
|
||||
cover: z.string().optional(),
|
||||
// BCP-47 tag when an article is not in English (e.g. "pl") — carried to
|
||||
// <html lang> so screen readers pick the right voice.
|
||||
lang: z.string().optional(),
|
||||
draft: z.boolean().default(false),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ date: 2026-07-23
|
||||
category: demo
|
||||
author: Cyfronet
|
||||
cover: /diagrams/cover-git-cms.svg
|
||||
lang: pl
|
||||
draft: false
|
||||
---
|
||||
|
||||
@@ -25,6 +26,13 @@ na stronie, bez żadnego ręcznego deployu. Każda zmiana to commit, więc
|
||||
|
||||

|
||||
|
||||
Dla porównania — ta sama maszyna z klasycznym CMS-em. Układ diagramu jest
|
||||
celowo identyczny, żeby różnice było widać na pierwszy rzut oka: tu każdy
|
||||
element działa bez przerwy, a panel logowania i aplikacja są wystawione
|
||||
do internetu.
|
||||
|
||||

|
||||
|
||||
## Gdzie co jest
|
||||
|
||||
| Co | Gdzie |
|
||||
@@ -79,6 +87,40 @@ researchu:
|
||||
planów (przykład wyżej). Gdy treść i jej historia leżą w gicie, nie ma
|
||||
funkcji, którą dostawca mógłby przenieść do płatnego planu.
|
||||
|
||||
## Plusy i minusy obu podejść
|
||||
|
||||
Portal ma być utrzymywany przez co najmniej 6 lat, więc bilans trzeba robić
|
||||
w dwóch horyzontach. Poniżej ocena podejść jako klas rozwiązań, nie
|
||||
konkretnych produktów.
|
||||
|
||||
**Krótkoterminowo (uruchomienie i pierwsze miesiące)**
|
||||
|
||||
| | Statyczne + git | CMS z bazą |
|
||||
|---|---|---|
|
||||
| Plusy | mało ruchomych części od pierwszego dnia; edytor graficzny gotowy; historia i rollback od razu, w gicie | bogatszy panel od ręki: role i uprawnienia, workflow redakcyjny, relacje między treściami, więcej gotowych integracji |
|
||||
| Minusy | uprawnienia proste (dziedziczone z gita — bez ról redakcyjnych); przeglądanie historii na razie w interfejsie gita, nie w edytorze | więcej komponentów do postawienia, spięcia i zabezpieczenia od pierwszego dnia (aplikacja, baza, backupy) |
|
||||
|
||||
**W horyzoncie 6 lat**
|
||||
|
||||
| | Statyczne + git | CMS z bazą |
|
||||
|---|---|---|
|
||||
| Plusy | brak publicznie działającej aplikacji = brak presji łatek bezpieczeństwa; koszt utrzymania bliski zera; treść w otwartym formacie przetrwa każdą wymianę narzędzi | jeśli z czasem będą potrzebne funkcje aplikacyjne (konta, personalizacja, treści silnie strukturalne), platforma już je ma |
|
||||
| Minusy | przy bardzo dużej skali (tysiące stron) buildy rosną i wymagają optymalizacji; zaawansowane funkcje redakcyjne zależą od tempa rozwoju narzędzi open-source | kilka dużych migracji wersji w 6 lat (zmiany łamiące); baza wymaga backupów i testów odtwarzania; stała powierzchnia ataku; ryzyko przenoszenia funkcji do płatnych planów |
|
||||
|
||||
## Gdzie jeszcze może się przydać
|
||||
|
||||
Ten sam wzorzec — edytor graficzny nad repozytorium Markdown — nie jest
|
||||
ograniczony do portalu. Naturalni kandydaci u nas:
|
||||
|
||||
- **Projekt Mickiewicza** — treści redagowane przez osoby nietechniczne,
|
||||
a publikowane jako strona statyczna.
|
||||
- **Dokumentacja Episodes Platform** — już dziś jest w Markdownie; podpięcie
|
||||
edytora dałoby wygodną edycję w przeglądarce zamiast edytora plików
|
||||
w Gitei, bez żadnych zmian w istniejącym repozytorium.
|
||||
|
||||
Koszt wdrożenia w takich miejscach jest niewielki: edytor to jeden statyczny
|
||||
plik HTML plus konfiguracja wskazująca repozytorium i strukturę treści.
|
||||
|
||||
## Brak lock-inu
|
||||
|
||||
Każdy element jest wymienialny osobno, bo treść to czysty Markdown:
|
||||
|
||||
+10
-3
@@ -2,12 +2,14 @@
|
||||
import '../styles/global.css';
|
||||
// `ambient` opts a page into the whole-page drifting brand wash (home only — see DESIGN.md)
|
||||
// `type`/`image` feed the social meta: articles pass type="article" + their cover.
|
||||
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal — demo', ambient = false, type = 'website', image } = Astro.props;
|
||||
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal — demo', ambient = false, type = 'website', image, lang = 'en' } = Astro.props;
|
||||
// Absolute URLs for canonical/OG/RSS (head pattern from the official Astro blog
|
||||
// template). `site` is a placeholder domain until the portal has a real one.
|
||||
const site = Astro.site ?? new URL('https://portal.ctao.org');
|
||||
const canonical = new URL(Astro.url.pathname, site);
|
||||
const ogImage = new URL(image || '/brand/og-card.jpg', site);
|
||||
// SVG covers never reach og:image — link-preview crawlers (Slack/Teams/
|
||||
// LinkedIn) don't render SVG, so those articles fall back to the brand card.
|
||||
const ogImage = new URL(image && !image.endsWith('.svg') ? image : '/brand/og-card.jpg', site);
|
||||
const path = Astro.url.pathname;
|
||||
const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href));
|
||||
// Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5).
|
||||
@@ -24,7 +26,7 @@ const links = [
|
||||
];
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@@ -42,6 +44,11 @@ const links = [
|
||||
<link rel="alternate" type="application/rss+xml" title="CTAO Science Portal — News" href={new URL('/rss.xml', site)} />
|
||||
<link rel="sitemap" href="/sitemap.xml" />
|
||||
<link rel="icon" href="/brand/CTAO_Logo_positive.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="theme-color" content="#00004a" />
|
||||
{/* The home hero photo is a CSS background, which browsers discover late —
|
||||
preloading it moves the page's LCP element to the front of the queue. */}
|
||||
{path === '/' && <link rel="preload" as="image" href="/brand/hero.jpg" fetchpriority="high" />}
|
||||
{/* Brand typography (BRAND D.3): Inter (body) + Space Grotesk (headlines),
|
||||
self-hosted latin woff2 (variable) — @font-face lives in global.css. */}
|
||||
{/* Both brand fonts ship inlined in the render-blocking stylesheet —
|
||||
|
||||
@@ -97,7 +97,7 @@ const planned = ['Scheduling', 'Science alerts'];
|
||||
<div class="body">
|
||||
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
|
||||
<p class="desc">{post.data.description}</p>
|
||||
<div class="meta"><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time> · {readMin(post)} min read</div>
|
||||
<div class="meta"><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8 3v4M16 3v4" /></svg><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></span><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>{readMin(post)} min read</span></div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -69,7 +69,7 @@ const hrefFor = (n) => (n === 1 ? '/news' : `/news/${n}`);
|
||||
<div class="body">
|
||||
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
|
||||
<p class="desc">{post.data.description}</p>
|
||||
<div class="meta"><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time> · {readMin(post)} min read</div>
|
||||
<div class="meta"><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8 3v4M16 3v4" /></svg><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></span><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>{readMin(post)} min read</span></div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -20,7 +20,10 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
||||
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
|
||||
const readMin = Math.max(1, Math.round((post.body ?? '').split(/\s+/).length / 220));
|
||||
---
|
||||
<Base title={`${post.data.title} — CTAO`} description={post.data.description} type="article" image={post.data.cover && encodeURI(post.data.cover)}>
|
||||
<Base title={`${post.data.title} — CTAO`} description={post.data.description} type="article" image={post.data.cover && encodeURI(post.data.cover)} lang={post.data.lang}>
|
||||
{/* Reading progress (scroll-driven CSS, no JS) — styled only where
|
||||
animation-timeline is supported; elsewhere it stays an empty div. */}
|
||||
<div class="read-progress" aria-hidden="true"></div>
|
||||
{/* TOC pattern (MDN/Stripe/NN-g): sticky right rail ≥1200px, collapsed <details>
|
||||
under the title below that. CSS shows exactly one of the two (DESIGN.md). */}
|
||||
<div class="container article-layout">
|
||||
|
||||
+50
-4
@@ -158,6 +158,7 @@ summary { cursor: pointer; list-style: none; }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
|
||||
a { color: var(--link); text-decoration: underline; text-underline-offset: 3px; }
|
||||
::selection { background: var(--galaxy); color: #fff; }
|
||||
a:hover { color: var(--indigo); }
|
||||
/* Placeholder links (<a> without href) don't pretend to be clickable */
|
||||
a:not([href]) { text-decoration: none; cursor: default; }
|
||||
@@ -220,10 +221,19 @@ img { max-width: 100%; height: auto; display: block; }
|
||||
.nav .spacer { flex: 1; }
|
||||
.nav a.navlink {
|
||||
color: var(--on-galaxy-muted); font-weight: 500; font-size: var(--fs-s);
|
||||
padding: 10px 10px; text-decoration: none; border-bottom: 2px solid transparent;
|
||||
padding: 10px 10px; text-decoration: none; position: relative;
|
||||
}
|
||||
/* Underline-grow (the current nav-hover convention): a 2px rule scales in
|
||||
from the left on hover and stays put on the active page. Transform-only;
|
||||
its transition lives in the no-preference block like every other motion. */
|
||||
.nav a.navlink::after {
|
||||
content: ""; position: absolute; left: 10px; right: 10px; bottom: 4px;
|
||||
height: 2px; border-radius: 1px; background: #fff;
|
||||
transform: scaleX(0); transform-origin: 0 50%;
|
||||
}
|
||||
.nav a.navlink:hover { color: #fff; }
|
||||
.nav a.navlink[aria-current="page"] { color: #fff; border-bottom-color: #fff; }
|
||||
.nav a.navlink:hover::after, .nav a.navlink[aria-current='page']::after { transform: scaleX(1); }
|
||||
.nav a.navlink[aria-current='page'] { color: #fff; }
|
||||
/* AAI sign-in entry — ghost/outline: Cherenkov fill is reserved for the page's
|
||||
primary CTA, the header only whispers. */
|
||||
.nav .login-mock {
|
||||
@@ -456,7 +466,9 @@ li.suggest-label {
|
||||
margin: 0; color: var(--muted); font-size: var(--fs-s);
|
||||
display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.card .meta { margin-top: auto; color: var(--muted); font-size: var(--fs-xs); }
|
||||
.card .meta { margin-top: auto; color: var(--muted); font-size: var(--fs-xs); display: flex; align-items: center; gap: 12px; }
|
||||
.card .meta > span { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.card .meta .ico { width: 13px; height: 13px; stroke-width: 1.8; }
|
||||
/* Lead story — larger, splits when the grid itself is wide enough (container query) */
|
||||
@container (min-width: 640px) {
|
||||
.card--featured { grid-column: 1 / -1; display: grid; grid-template-columns: 3fr 2fr; }
|
||||
@@ -510,7 +522,12 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
|
||||
.article .meta .ico { width: 15px; height: 15px; stroke-width: 1.8; }
|
||||
/* Lead image — borderless, breaking slightly out of the text column on wide
|
||||
screens (editorial breakout; the column stays 70ch for reading) */
|
||||
.article .cover { border-radius: var(--radius); margin-bottom: var(--space-l); }
|
||||
.article .cover {
|
||||
border-radius: var(--radius); margin-bottom: var(--space-l);
|
||||
/* Reserved box = no layout shift while the cover loads; also normalizes
|
||||
editor-uploaded images of any proportions to the editorial 16:9 lead. */
|
||||
width: 100%; aspect-ratio: 16 / 9; object-fit: cover;
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.article .cover { margin-inline: -40px; width: calc(100% + 80px); max-width: none; }
|
||||
}
|
||||
@@ -783,6 +800,32 @@ mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(va
|
||||
.card, .search-results li { transition: box-shadow var(--dur) var(--ease), transform var(--dur) var(--ease); }
|
||||
.suggest li { transition: background-color var(--dur) var(--ease); } /* same quiet fade as every hover */
|
||||
.card:hover, .search-results li:hover { transform: translateY(-2px); } /* the one elevate gesture (no cover zoom) */
|
||||
|
||||
/* Nav underline-grow + press feedback (micro-interaction pair) */
|
||||
.nav a.navlink::after { transition: transform var(--dur) var(--ease); }
|
||||
.btn:active, .nav a.navlink:active { transform: translateY(1px); }
|
||||
|
||||
/* === Scroll-driven effects (CSS only, compositor-only, no JS) ===
|
||||
Progressive enhancement behind @supports; the reduce block above
|
||||
force-disables all animation, so these obey the user preference too. */
|
||||
@supports (animation-timeline: view()) {
|
||||
/* Cards drift in as they enter the viewport; items already on screen
|
||||
start past the entry range, so nothing flashes at load. */
|
||||
.card { animation: card-in linear both; animation-timeline: view(); animation-range: entry 0% entry 35%; }
|
||||
/* Header casts its shadow only once the page is actually scrolled. */
|
||||
.site-header::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: -1;
|
||||
box-shadow: var(--shadow-lifted); opacity: 0;
|
||||
animation: fade-in linear both; animation-timeline: scroll(); animation-range: 0 80px;
|
||||
}
|
||||
/* Article reading progress — functional state, same Cherenkov budget line
|
||||
as the TOC scrollspy rule (navigation feedback, not decoration). */
|
||||
.read-progress {
|
||||
position: fixed; top: 56px; left: 0; width: 100%; height: 2px; z-index: 9;
|
||||
background: var(--cherenkov); transform-origin: 0 50%;
|
||||
animation: progress linear both; animation-timeline: scroll(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
@keyframes nebula { /* Gemini-style aurora: slow, ample, gentle breath */
|
||||
from { transform: translate3d(-9%, -6%, 0) rotate(-5deg) scale(1); opacity: 0.85; }
|
||||
@@ -797,3 +840,6 @@ mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(va
|
||||
to { transform: translate3d(5%, 5%, 0) rotate(10deg) scale(1.16); opacity: 1; }
|
||||
}
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(14px); } }
|
||||
@keyframes card-in { from { opacity: 0; transform: translateY(14px); } }
|
||||
@keyframes fade-in { from { opacity: 0; } }
|
||||
@keyframes progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||
|
||||
Reference in New Issue
Block a user