ui iteration: nav underline moved to ::before (restores external arrow on Data), card reveal tuned (entry 75%, 28px), reading progress 3px, permanent lifted header shadow; article honesty pass: app-in-path class line (Grav/Tina clarified), 'is Gitea a second CMS' section, no-VPN claim removed

This commit is contained in:
2026-07-29 07:55:22 +02:00
parent c6de06f017
commit a74eef0e47
3 changed files with 59 additions and 23 deletions
+1 -1
View File
@@ -44,5 +44,5 @@
<path class="flow" d="M716 247 H772"/> <path class="flow" d="M716 247 H772"/>
<g filter="url(#soft3)"><rect class="good" x="776" y="210" width="360" height="76" rx="16"/></g> <g filter="url(#soft3)"><rect class="good" x="776" y="210" width="360" height="76" rx="16"/></g>
<text class="t item" x="804" y="242">▪ git (Gitea) — tylko zalogowani</text> <text class="t item" x="804" y="242">▪ git (Gitea) — tylko zalogowani</text>
<text class="t cap" x="804" y="268">docelowo za VPN, poza publicznym internetem</text> <text class="t cap" x="804" y="268">poza ścieżką czytelnika — awaria nie kładzie strony</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

+44 -8
View File
@@ -11,8 +11,11 @@ draft: false
Ten portal to **statyczne pliki HTML** generowane z plików Markdown trzymanych Ten portal to **statyczne pliki HTML** generowane z plików Markdown trzymanych
w repozytorium git. Redaktor dostaje **graficzny edytor w przeglądarce** — w repozytorium git. Redaktor dostaje **graficzny edytor w przeglądarce** —
a mimo to w produkcji nie działa żadna aplikacja, żadna baza danych. a mimo to **po stronie publicznej** nie działa żadna aplikacja ani baza:
Ten artykuł został napisany i opublikowany dokładnie tą ścieżką. między czytelnikiem a treścią jest wyłącznie serwer plików. Jedyny działający
serwis (Gitea) obsługuje edycję i stoi poza ścieżką czytelnika — wracamy do
tego uczciwie niżej. Ten artykuł został napisany i opublikowany dokładnie tą
ścieżką.
## Od zapisu do publikacji ## Od zapisu do publikacji
@@ -62,14 +65,25 @@ i jednorazowy kontener builda odpalany przez systemd timer.
## Dwie klasy rozwiązań ## Dwie klasy rozwiązań
| | Statyczne + git-CMS *(to demo)* | CMS z własną bazą | Linia podziału jest często mylona: nie chodzi o to, czy treść leży w bazie
czy w plikach, tylko o to, **czy między czytelnikiem a treścią stoi
działająca aplikacja**.
| | Statyczne + git-CMS *(to demo)* | CMS serwujący stronę aplikacją |
|---|---|---| |---|---|---|
| Przykłady | Sveltia, Decap (+ Astro / Hugo / Eleventy) | Strapi, WordPress, Grav, TinaCMS | | Przykłady | Sveltia, Decap (+ Astro / Hugo / Eleventy) | Strapi, WordPress, Drupal, Grav, TinaCMS |
| Treść żyje w | plikach Markdown w gicie | bazie danych | | Treść żyje w | plikach Markdown w gicie | zwykle w bazie; Grav — w plikach, Tina — w gicie |
| W produkcji działa | serwer plików | aplikacja + baza, non stop | | W produkcji działa | serwer plików | aplikacja (zwykle + baza), non stop |
| Utrzymanie | skrypt builda | patche, aktualizacje, backupy bazy | | Utrzymanie | skrypt builda + serwis gitowy | patche, aktualizacje, backupy bazy |
| Historia zmian | git, wbudowana | zależnie od produktu (bywa płatna) | | Historia zmian | git, wbudowana | zależnie od produktu (bywa płatna) |
Dwa doprecyzowania, żeby tabela nie upraszczała: **Grav** trzyma treść
w plikach bez żadnej bazy — ale stronę i tak serwuje aplikacja PHP, więc pod
względem powierzchni ataku i utrzymania należy do prawej kolumny. **TinaCMS**
trzyma treść wręcz w Markdownie w gicie, ale do działania edytora wymaga
stale działającego backendu z bazą — jest hybrydą, która dziedziczy koszty
obu światów.
Ograniczenia poszczególnych rozwiązań, które zweryfikowaliśmy podczas Ograniczenia poszczególnych rozwiązań, które zweryfikowaliśmy podczas
researchu: researchu:
@@ -104,9 +118,31 @@ konkretnych produktów.
| | Statyczne + git | CMS z bazą | | | 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 | | Plusy | strona publiczna bez aplikacji = brak presji łatek na ścieżce czytelnika; do utrzymania zostaje serwis gitowy (najlepiej ten, który zespół i tak ma); 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 | | 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 |
## „Czy Gitea to nie jest po prostu drugi CMS?"
Uczciwe pytanie: Gitea też ma logowanie, bazę i wymaga aktualizacji
bezpieczeństwa, a dla edytorów musi być dostępna spoza VPN. Czy różnica nie
sprowadza się więc do tego, że zamiast CMS-a utrzymujemy Giteę?
Częściowo tak — i dlatego nie twierdzimy, że utrzymanie jest zerowe. Różnica
polega na trzech rzeczach:
- **Gitea stoi poza ścieżką czytelnika.** Może mieć przerwę, aktualizację,
a nawet zostać przejęta — portal dalej stoi i serwuje bezpieczne pliki.
Najgorszy scenariusz włamania to wandalizm treści: widoczny, z pełną
historią, odwracalny jednym revertem. W CMS-ie serwującym stronę ten sam
incydent oznacza przejętą stronę publiczną.
- **Zespół już utrzymuje Giteę.** Docelowo repozytorium treści może żyć na
istniejącej instancji zespołu — wtedy krańcowy koszt utrzymania jest
bliski zera, a logowanie załatwia jedna integracja z AAI.
- **Klasa oprogramowania.** Gitea to pojedynczy program w Go z SQLite:
aktualizacja to podmiana obrazu i restart, bez ekosystemu pluginów i bez
drzewa zależności npm po stronie serwera. Platformy CMS to frameworki
aplikacyjne z regularnymi migracjami głównych wersji.
## Gdzie jeszcze może się przydać ## Gdzie jeszcze może się przydać
Ten sam wzorzec — edytor graficzny nad repozytorium Markdown — nie jest Ten sam wzorzec — edytor graficzny nad repozytorium Markdown — nie jest
+14 -14
View File
@@ -193,7 +193,9 @@ img { max-width: 100%; height: auto; display: block; }
/* Header — Galaxy Blue band, reverse (white) logo per BRAND B.1.2; translucent /* Header — Galaxy Blue band, reverse (white) logo per BRAND B.1.2; translucent
over content where backdrop blur is supported (AA computed: worst underlying over content where backdrop blur is supported (AA computed: worst underlying
white → bar (31,31,96), muted nav text 9.4:1). Solid Galaxy fallback. */ white → bar (31,31,96), muted nav text 9.4:1). Solid Galaxy fallback. */
.site-header { background: var(--galaxy); position: sticky; top: 0; z-index: 10; } /* Lifted shadow separates the bar (and the mobile dropdown panels
underneath it) from content — the header floats above the page. */
.site-header { background: var(--galaxy); position: sticky; top: 0; z-index: 10; box-shadow: var(--shadow-lifted); }
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { @supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
.site-header { .site-header {
background: color-mix(in srgb, var(--galaxy) 88%, transparent); background: color-mix(in srgb, var(--galaxy) 88%, transparent);
@@ -225,14 +227,15 @@ img { max-width: 100%; height: auto; display: block; }
} }
/* Underline-grow (the current nav-hover convention): a 2px rule scales in /* 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; 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. */ its transition lives in the no-preference block like every other motion.
.nav a.navlink::after { ::before by codebase convention — ::after belongs to the external arrow. */
.nav a.navlink::before {
content: ""; position: absolute; left: 10px; right: 10px; bottom: 4px; content: ""; position: absolute; left: 10px; right: 10px; bottom: 4px;
height: 2px; border-radius: 1px; background: #fff; height: 2px; border-radius: 1px; background: #fff;
transform: scaleX(0); transform-origin: 0 50%; transform: scaleX(0); transform-origin: 0 50%;
} }
.nav a.navlink:hover { color: #fff; } .nav a.navlink:hover { color: #fff; }
.nav a.navlink:hover::after, .nav a.navlink[aria-current='page']::after { transform: scaleX(1); } .nav a.navlink:hover::before, .nav a.navlink[aria-current='page']::before { transform: scaleX(1); }
.nav a.navlink[aria-current='page'] { color: #fff; } .nav a.navlink[aria-current='page'] { color: #fff; }
/* AAI sign-in entry — ghost/outline: Cherenkov fill is reserved for the page's /* AAI sign-in entry — ghost/outline: Cherenkov fill is reserved for the page's
primary CTA, the header only whispers. */ primary CTA, the header only whispers. */
@@ -802,7 +805,7 @@ mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(va
.card:hover, .search-results li:hover { transform: translateY(-2px); } /* the one elevate gesture (no cover zoom) */ .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 underline-grow + press feedback (micro-interaction pair) */
.nav a.navlink::after { transition: transform var(--dur) var(--ease); } .nav a.navlink::before { transition: transform var(--dur) var(--ease); }
.btn:active, .nav a.navlink:active { transform: translateY(1px); } .btn:active, .nav a.navlink:active { transform: translateY(1px); }
/* === Scroll-driven effects (CSS only, compositor-only, no JS) === /* === Scroll-driven effects (CSS only, compositor-only, no JS) ===
@@ -811,17 +814,14 @@ mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(va
@supports (animation-timeline: view()) { @supports (animation-timeline: view()) {
/* Cards drift in as they enter the viewport; items already on screen /* Cards drift in as they enter the viewport; items already on screen
start past the entry range, so nothing flashes at load. */ 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%; } /* Range tuned on a real trackpad: entry 0–35% completed within a flick
/* Header casts its shadow only once the page is actually scrolled. */ and read as "no effect"; 0–75% keeps the drift visible at normal
.site-header::before { scrolling speed while still finishing before the card is fully in. */
content: ""; position: absolute; inset: 0; z-index: -1; .card { animation: card-in linear both; animation-timeline: view(); animation-range: entry 0% entry 75%; }
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 /* Article reading progress — functional state, same Cherenkov budget line
as the TOC scrollspy rule (navigation feedback, not decoration). */ as the TOC scrollspy rule (navigation feedback, not decoration). */
.read-progress { .read-progress {
position: fixed; top: 56px; left: 0; width: 100%; height: 2px; z-index: 9; position: fixed; top: 56px; left: 0; width: 100%; height: 3px; z-index: 9;
background: var(--cherenkov); transform-origin: 0 50%; background: var(--cherenkov); transform-origin: 0 50%;
animation: progress linear both; animation-timeline: scroll(root); animation: progress linear both; animation-timeline: scroll(root);
} }
@@ -840,6 +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; } to { transform: translate3d(5%, 5%, 0) rotate(10deg) scale(1.16); opacity: 1; }
} }
@keyframes rise { from { opacity: 0; transform: translateY(14px); } } @keyframes rise { from { opacity: 0; transform: translateY(14px); } }
@keyframes card-in { from { opacity: 0; transform: translateY(14px); } } @keyframes card-in { from { opacity: 0; transform: translateY(28px); } }
@keyframes fade-in { from { opacity: 0; } } @keyframes fade-in { from { opacity: 0; } }
@keyframes progress { from { transform: scaleX(0); } to { transform: scaleX(1); } } @keyframes progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }