Większość projektów webowych wybiera bibliotekę ikon pierwszego dnia i nigdy więcej o ikonach nie myśli. My zrobiliśmy odwrotnie: najpierw wypuściliśmy stronę, pozwoliliśmy kategoriom wykrystalizować się przez wiele fal treści, a dopiero potem zaprojektowaliśmy zestaw ikon. Rezultatem jest słownik symboli ściśle powiązany z rzeczywistą tematyką — glif wiru dynamiki płynów nie przypomina generycznej ikony „odśwież", a ta specyficzność okazuje się mieć ogromne znaczenie dla zrozumienia materiału przez uczącego się.
System sprite'ów SVG złożony z 91 symboli
Sprite sheet to pojedynczy plik .svg, który zawiera wiele elementów <symbol>, każdy z unikalnym id. Dowolna strona serwisu może odwołać się do nich jedną linijką:
<!-- wstawiane raz na stronę, ukryte --> <svg style="display:none" aria-hidden="true"> <use href="/shared/icons.svg#wave"/> </svg> <!-- użycie w dowolnym miejscu --> <svg class="icon" aria-hidden="true"> <use href="/shared/icons.svg#wave"/> </svg>
Takie podejście oznacza, że każda ikona na każdej stronie jest serwowana z jednego, buforowanego pliku. Ponieważ każdy <symbol> dziedziczy currentColor, można tonować ikony jedną właściwością CSS — bez potrzeby utrzymywania osobnych wariantów kolorystycznych.
Ustalenie, które dokładnie 91 symboli uwzględnić, wymagało zmapowania każdej symulacji na główny koncept wizualny. To ćwiczenie ujawniło trzy rodziny ikon:
- Prymitywy fizyczne — fala, linia pola, cząstka, wektor, orbita, wahadło, sprężyna, zderzenie.
- Znaczniki dziedzinowe — helisa DNA, błona komórkowa, węzeł obwodu, warstwa geologiczna, pryzmat, soczewka, sieć neuronowa.
- Kontrolki UI — odtwórz, pauza, reset, przyspiesz, pobierz, pełny ekran, ustawienia, udostępnij.
Każdy symbol został narysowany na płótnie 24×24 z grubością linii 1,5 px — na tyle cienką, by być czytelną w małym rozmiarze, i na tyle wyrazistą, by rejestrować się od razu. Ścieżki były ręcznie odrysowane w Inkscape, a następnie przetworzone skryptem Node, który usuwa atrybuty fill, stroke i style, tak by CSS konsumujący te symbole miał nad nimi pełną kontrolę.
// strip-attrs.mjs — przetwarzanie wyeksportowanych symboli SVG import { readFileSync, writeFileSync } from 'node:fs'; const src = readFileSync('icons-raw.svg', 'utf8'); const clean = src .replace(/ fill="[^"]*"/g, '') .replace(/ stroke="[^"]*"/g, '') .replace(/ style="[^"]*"/g, ''); writeFileSync('public/shared/icons.svg', clean);
Jedną decyzję architektoniczną dyskutowaliśmy długo: czy symbole powinny nieść domyślne fill="none" stroke="currentColor"? Ostatecznie umieściliśmy te wartości domyślne w klasie CSS .icon, a nie bezpośrednio w SVG — dzięki temu znacznik sprite'u pozostaje pozbawiony stylów, a więc możliwy do komponowania w dowolnym kontekście.
Dlaczego zrezygnowaliśmy z Lucide (i każdej innej biblioteki)
Lucide to doskonały zestaw ikon do interfejsów produktowych — formularzy logowania, dashboardów, menu nawigacyjnych. Ma około 1500 symboli i jest utrzymywany przez dedykowany zespół. Świadomie zrezygnowaliśmy z jego użycia z trzech konkretnych powodów.
1. Niedopasowanie semantyczne
„Fala" w Lucide to dekoracyjny, sinusoidalny zawijas. „Fala" w naszej kategorii fizyki musi przekazywać długość fali λ, amplitudę A i poczucie, że kształt się propaguje — najlepiej przywołując relację dyspersji ω² = gk dla fal powierzchniowych. Generyczne ikony po prostu nie niosą takiej gęstości informacji; uczący się przeglądający siatkę kategorii odczytuje nasz własny glif jako „fizykę fal", a ikonę Lucide jako zwykłą „dekorację".
2. Koszt paczki kontra rzeczywistość
Nawet po tree-shakingu Lucide wymaga zaimportowania osobnego komponentu na każdą ikonę, dodając ~1–2 KB na symbol po gzipie. Cały nasz sprite złożony z 91 symboli waży 18 KB po kompresji i jest cache'owany bezterminowo. Punkt, w którym biblioteka byłaby lżejsza, leży gdzieś powyżej ~200 ikon użytych na całej stronie — czyli znacznie więcej, niż nam potrzeba.
3. Ograniczenia wypełnień SVG
Kilka symboli Lucide ma na wewnętrznych kształtach sztywno wpisane atrybuty fill, wynikające ze stylistycznego warstwowania. Gdy próbujesz przestylizować je przez color, te wypełnienia nie reagują na currentColor tak, jak byśmy oczekiwali. Audytowanie i łatanie zewnętrznej biblioteki wprowadza obciążenie utrzymaniowe, które rośnie z każdą zmianą wersji upstream. Nasz autorski sprite nie ma tego rodzaju ukrytych atrybutów — grep -r 'fill=' public/shared/icons.svg zwraca zero dopasowań.
Potok gen-previews.mjs oparty na Puppeteer
Obrazy Open Graph to pierwsza rzecz, jaką widzi użytkownik, gdy link do symulacji jest udostępniany w mediach społecznościowych lub komunikatorze. Dla ponad 350 symulacji potrzebowaliśmy odtwarzalnego, zautomatyzowanego sposobu ich generowania — a nie folderu ręcznie wyeksportowanych PNG-ów.
Naszym rozwiązaniem jest gen-previews.mjs — skrypt Node, który za pomocą Puppeteera w trybie headless ładuje każdą stronę symulacji, czeka, aż canvas WebGL wyrenderuje pierwszą klatkę, a następnie robi zrzut ekranu 1200×630:
// gen-previews.mjs (uproszczony fragment) import puppeteer from 'puppeteer'; import { readFileSync } from 'node:fs'; const sims = JSON.parse(readFileSync('seeds.json', 'utf8')); const browser = await puppeteer.launch({ args: ['--no-sandbox'] }); for (const sim of sims) { const page = await browser.newPage(); await page.setViewport({ width: 1200, height: 630 }); await page.goto(`http://localhost:4321/sims/${sim.slug}/`); // czekamy, aż canvas będzie miał niezerowe piksele await page.waitForFunction(() => { const c = document.querySelector('canvas'); if (!c) return false; const ctx = c.getContext('2d'); const d = ctx.getImageData(0, 0, 4, 4).data; return d.some(v => v > 0); }, { timeout: 8000 }); await page.screenshot({ path: `public/preview/${sim.slug}.jpg`, type: 'jpeg', quality: 82 }); await page.close(); } await browser.close();
Sprawdzenie gotowości canvasu — próbkowanie bloku pikseli 4×4 w lewym górnym rogu — to kluczowa heurystyka. Pusty canvas ma wszystkie wartości RGBA zerowe; wyrenderowana klatka WebGL — nie. To pozwala uniknąć arbitralnych opóźnień setTimeout, które byłyby albo za krótkie (pusty zrzut ekranu), albo za długie (potok się zawiesza).
Pułapka: konteksty WebGL tworzone w headless Puppeteerze domyślnie używają programowego rasteryzatora, jeśli nie przekażesz --use-gl=swiftshader w argumentach uruchomienia przeglądarki. Bez tego symulacje wywołujące getContext('webgl2') po cichu wracają do pustego canvasu, a sprawdzenie pikseli nigdy się nie kończy.
Potok wykonuje się w około 12 minut dla 350 symulacji na runnerze CI z 4 vCPU. Katalog wynikowy public/preview/ cache'ujemy między uruchomieniami za pomocą Actions cache action, kluczując hashem seeds.json — dzięki czemu ponownie zrzucane są tylko nowe lub zmienione symulacje.
seeds.json — ustrukturyzowane metadane do generowania obrazów przez AI
Nie każda symulacja daje wizualnie wyrazisty zrzut pierwszej klatki. Niektóre zaczynają w statycznym stanie początkowym: pusty oscylator czekający na zaburzenie od użytkownika albo siatka automatu komórkowego stojąca na zerze. Dla takich przypadków używamy seeds.json, by zamiast tego określić grafikę wygenerowaną przez AI jako obraz OG.
Każdy wpis w seeds.json opisuje symulację z kontekstem wystarczającym, by model generowania obrazów mógł stworzyć reprezentatywny obraz:
{
"slug": "reaction-diffusion",
"title": "Reaction-Diffusion Patterns",
"category": "chemistry",
"ogStrategy": "ai-generated",
"prompt": "Turing pattern, Belousov–Zhabotinsky spirals, teal and dark background, macro photography style, scientific, no text",
"negativePrompt": "cartoon, watermark, logo, UI chrome",
"aspectRatio": "1200x630",
"iconSymbol": "chemistry-spiral"
}
Pole iconSymbol wiąże każdy wpis z powrotem ze sprite'em SVG, więc ten sam manifest metadanych napędza zarówno strategię obrazu OG, jak i ikonę karty kategorii pokazywaną na stronie. Posiadanie jednego źródła prawdy dla obu tych kwestii zapobiega sytuacji, w której symulacja pokazuje na ekranie ikonę dynamiki płynów, a przy udostępnianiu w mediach społecznościowych — obraz krystalografii.
Pole ogStrategy może przyjmować jedną z trzech wartości: "puppeteer" (żywy zrzut ekranu), "ai-generated" (na podstawie promptu) lub "static" (ręcznie dostarczona grafika). Potok odczytuje to pole i odpowiednio kieruje każdą symulację, dzięki czemu łatwo nadpisać domyślne zachowanie zrzutu ekranu dla konkretnych wpisów, nie dotykając samego skryptu.
Wypróbuj sam
System ikon i potok OG zostały zbudowane, by obsługiwać właśnie takie symulacje — otwórz je i zobacz, co przechwytuje generator zrzutów:
Myśl na zakończenie
Lekcja płynąca z budowy tego systemu jest taka, że infrastrukturę wizualną warto projektować od pierwszych zasad danej dziedziny, a nie zakładać, że biblioteka ogólnego przeznaczenia będzie „wystarczająco dobra". Dla strony, której cały cel to uczynienie abstrakcyjnych pojęć naukowych namacalnymi, ikony i obrazy podglądowe nie są dekoracją — są częścią wyjaśnienia. Uczący się, który widzi precyzyjnie narysowany glif linii pola obok słowa „Elektromagnetyzm", już zaczął budować właściwy model mentalny, zanim jeszcze kliknął.
Wzorzec manifestu seeds.json to coś, co zamierzamy dalej rozwijać: kolejne fale będą wykorzystywać pole iconSymbol do napędzania animowanych wariantów miniatur, gdzie symbol sprite'u służy jako maska przycinająca dla zapętlonego canvasu WebGL. Dzięki temu sama siatka kategorii stanie się żywym podglądem każdej zawartej w niej symulacji.
W następnym devlogu przyjrzymy się architekturze wyszukiwania, która indeksuje wszystkie ponad 350 symulacji po stronie klienta bez backendu — zaskakująco trudny problem, gdy Twoje słownictwo obejmuje terminy takie jak „równania Naviera–Stokesa", „skala Kołmogorowa" i „rozkład na wektory własne".