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:
2026-07-28 20:57:51 +02:00
parent 54de9d3ee9
commit bbbb090cce
13 changed files with 236 additions and 15 deletions
+3
View File
@@ -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
![Architektura na maszynie demo: redaktor zapisuje przez Sveltię do Gitei, timer systemd uruchamia jednorazowy kontener builda Astro, wynik trafia do wolumenu releases, a nginx serwuje czytelnikom wyłącznie statyczne pliki](/diagrams/architektura-maszyna.svg)
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.
![Architektura klasycznego CMS z bazą danych: aplikacja z publicznym panelem admina i renderowaniem na żądanie działa non stop, obok baza danych wymagająca backupów i wolumen mediów](/diagrams/architektura-cms-z-baza.svg)
## 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
View File
@@ -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 —
+1 -1
View File
@@ -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>
))}
+1 -1
View File
@@ -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>
))}
+4 -1
View File
@@ -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
View File
@@ -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); } }