Build the Astro site UI

This commit is contained in:
Andras Schmelczer 2026-05-25 13:12:10 +01:00
parent e5a219499e
commit f27e9ec3fd
84 changed files with 3510 additions and 1949 deletions

View file

@ -0,0 +1,53 @@
---
import type { CollectionEntry } from 'astro:content';
import EntryThumbnail from './EntryThumbnail.astro';
import TagList from './TagList.astro';
import { ARTICLE_THUMBNAIL, articlePath, formatDate, formatDateShort } from '../lib/site';
interface Props {
posts: CollectionEntry<'posts'>[];
showYear?: boolean;
tagLimit?: number;
// Opt-in: eagerly load the first thumbnail. Only set when the list is
// reliably above the fold (home, tag pages). Lists below substantial
// content (related, archives by year, 404) should leave this off.
eagerFirstThumbnail?: boolean;
}
const { posts, showYear = true, tagLimit = 3, eagerFirstThumbnail = false } = Astro.props;
---
<ol class="article-list">
{
posts.map((post, index) => {
const href = articlePath(post);
return (
<li>
<article>
<h3>
<a class="entry-title" href={href}>
{post.data.title}
</a>
</h3>
<p>{post.data.description}</p>
<TagList tags={post.data.tags} limit={tagLimit} />
</article>
<time datetime={post.data.date.toISOString()}>
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
</time>
<EntryThumbnail
src={post.data.thumbnail.src}
alt={post.data.thumbnail.alt}
href={href}
class="article-thumbnail"
widths={ARTICLE_THUMBNAIL.widths}
sizes={ARTICLE_THUMBNAIL.sizes}
ariaLabel={`Open article: ${post.data.title}`}
loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
/>
</li>
);
})
}
</ol>

View file

@ -0,0 +1,50 @@
---
import type { CollectionEntry } from 'astro:content';
import ProjectLinks from './ProjectLinks.astro';
type Link = CollectionEntry<'projects'>['data']['links'][number];
interface Props {
role?: string;
projectPeriod?: string;
stack?: string[];
scale?: string;
outcome?: string;
links?: Link[];
headingId: string;
}
const {
role,
projectPeriod,
stack = [],
scale,
outcome,
links = [],
headingId,
} = Astro.props;
const rows: Array<[string, string]> = [];
if (role) rows.push(['Role', role]);
if (projectPeriod) rows.push(['Period', projectPeriod]);
if (stack.length > 0) rows.push(['Stack', stack.join(', ')]);
if (scale) rows.push(['Scale', scale]);
if (outcome) rows.push(['Outcome', outcome]);
---
{
rows.length > 0 && (
<aside class="at-a-glance" aria-labelledby={headingId}>
<h2 id={headingId}>At a Glance</h2>
<dl>
{rows.map(([label, value]) => (
<div class="at-a-glance__row">
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
{links.length > 0 && <ProjectLinks links={links} />}
</aside>
)
}

View file

@ -0,0 +1,33 @@
---
interface Crumb {
href?: string;
label: string;
}
interface Props {
items: Crumb[];
}
const { items } = Astro.props;
const lastIndex = items.length - 1;
---
<nav aria-label="Breadcrumb">
<ol class="breadcrumbs">
{
items.map((item, index) => {
const isLast = index === lastIndex;
const isLink = item.href && !isLast;
return (
<li>
{isLink ? (
<a href={item.href}>{item.label}</a>
) : (
<span aria-current={isLast ? 'page' : undefined}>{item.label}</span>
)}
</li>
);
})
}
</ol>
</nav>

View file

@ -0,0 +1,56 @@
---
import type { ImageMetadata } from 'astro';
import { Picture } from 'astro:assets';
interface Props {
src: ImageMetadata;
alt: string;
href?: string;
class?: string;
widths: number[];
sizes: string;
loading?: 'lazy' | 'eager';
fetchpriority?: 'high' | 'low' | 'auto';
ariaLabel?: string;
// When the listing already has a focusable, screen-reader-visible title
// link, the thumbnail link is visually duplicative. We keep it clickable
// for pointer users but drop it from the tab order. The link still needs
// a name because some assistive tech exposes non-tabbable links.
decorative?: boolean;
}
const {
src,
alt,
href,
class: extraClass,
widths,
sizes,
loading = 'lazy',
fetchpriority,
ariaLabel,
decorative = true,
} = Astro.props;
const Tag = href ? 'a' : 'div';
const isDecorativeLink = Boolean(href) && decorative;
---
<Tag
class:list={['entry-thumbnail', extraClass]}
href={href}
tabindex={isDecorativeLink ? -1 : undefined}
aria-label={isDecorativeLink ? (ariaLabel ?? alt) : undefined}
>
<Picture
src={src}
alt={isDecorativeLink ? '' : alt}
formats={['avif', 'webp']}
fallbackFormat="jpg"
widths={widths}
sizes={sizes}
loading={loading}
decoding="async"
fetchpriority={fetchpriority}
/>
</Tag>

View file

@ -0,0 +1,32 @@
---
import { navItems, site } from '../lib/site';
const year = new Date().getFullYear();
// Footer shows all nav items except Home (which is implicit via the site title).
const footerNavItems = navItems.filter((item) => item.href !== '/');
---
<footer class="site-footer">
<nav aria-label="Footer">
<ul class="footer-links">
{
footerNavItems.map((item) => (
<li>
<a href={item.href}>{item.label}</a>
</li>
))
}
</ul>
</nav>
<div class="footer-meta">
<span>© {year} {site.name}</span>
{/* address wraps only the author's contact details, per HTML spec. */}
<address class="footer-contact">
<a href={`mailto:${site.email}`}>Email</a>
<a href={site.cv} rel="noopener">CV</a>
<a href={site.github} rel="noopener me">GitHub</a>
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
</address>
</div>
</footer>

128
src/components/Header.astro Normal file
View file

@ -0,0 +1,128 @@
---
import { navItems, site } from '../lib/site';
const currentPath = Astro.url.pathname;
const current =
currentPath === '/' || currentPath.endsWith('/') || /\.[^/]+$/.test(currentPath)
? currentPath
: `${currentPath}/`;
// Exact match for the current page; section match (descendant URLs) for
// ancestor links. `aria-current="page"` is reserved for the exact page,
// `"true"` indicates an ancestor section.
function currentState(href: string): 'page' | 'true' | undefined {
if (current === href) return 'page';
if (href !== '/' && current.startsWith(href)) return 'true';
return undefined;
}
// Header shows nav items except Home and footer-only entries. RSS lives as a
// dedicated icon link to the right of the nav.
const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.footerOnly);
---
<a class="skip-link" href="#content">Skip to content</a>
<header class="site-header">
<a class="site-title" href="/" aria-current={currentState('/')}>{site.name}</a>
<div class="header-actions">
<nav class="site-nav" aria-label="Primary">
{
headerNavItems.map((item) => (
<a href={item.href} aria-current={currentState(item.href)}>
{item.label}
</a>
))
}
</nav>
<a class="rss-link" href="/rss.xml" aria-label="RSS feed">
<svg
class="rss-icon"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M6.18 17.82a2.18 2.18 0 1 1-4.36 0 2.18 2.18 0 0 1 4.36 0ZM2 9.86v3.13a8.97 8.97 0 0 1 9.01 9.01h3.13A12.1 12.1 0 0 0 2 9.86Zm0-5.86V7.1A14.92 14.92 0 0 1 16.9 22H20A17.9 17.9 0 0 0 2 4Z"
></path>
</svg>
<span class="sr-only">RSS feed</span>
</a>
<button
id="theme-switcher"
class="theme-switcher"
type="button"
aria-label="Dark theme"
aria-pressed="false"
>
<span class="sr-only">Toggle theme</span>
</button>
</div>
</header>
<script is:inline data-theme-script>
// Co-located with the button so the initial aria state is set as soon as the
// button parses, avoiding a flash of the wrong icon. The theme itself is
// already on <html> from theme-init.js in <head>.
(function () {
var root = document.documentElement;
var switcher = document.getElementById('theme-switcher');
if (!switcher) return;
// Keep in sync with --color-bg in global.css and theme-init.js.
var THEME_BG = { light: '#fbfaf7', dark: '#151514' };
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
function sync(theme) {
switcher.setAttribute('aria-pressed', String(theme === 'dark'));
switcher.setAttribute(
'title',
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'
);
for (var i = 0; i < themeColorMetas.length; i += 1) {
themeColorMetas[i].setAttribute('content', THEME_BG[theme]);
}
}
sync(root.dataset.theme === 'dark' ? 'dark' : 'light');
var reduced = matchMedia('(prefers-reduced-motion: reduce)');
switcher.addEventListener('click', function () {
var next = root.dataset.theme === 'dark' ? 'light' : 'dark';
try {
localStorage.setItem('theme', next);
} catch (e) {}
var run = function () {
root.dataset.theme = next;
root.style.colorScheme = next;
sync(next);
};
if (!reduced.matches && typeof document.startViewTransition === 'function') {
document.startViewTransition(run);
} else {
run();
}
});
})();
</script>
<style>
.rss-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-block-size: 44px;
min-inline-size: 44px;
color: inherit;
line-height: 0;
transition: color 150ms ease;
}
.rss-link:hover,
.rss-link:focus-visible {
color: var(--color-link-hover);
}
.rss-icon {
display: block;
}
</style>

View file

@ -0,0 +1,30 @@
---
import type { CollectionEntry } from 'astro:content';
import PostMediaFigure from './PostMediaFigure.astro';
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
interface Props {
items: MediaItem[];
}
const { items } = Astro.props;
// Wrap in a gallery `<ul>` when there's more than one item; otherwise the
// figures sit directly in the post flow.
const isGallery = items.length > 1;
---
{
isGallery ? (
<ul role="list" class="post-gallery">
{items.map((item) => (
<li>
<PostMediaFigure item={item} />
</li>
))}
</ul>
) : (
items.map((item) => <PostMediaFigure item={item} />)
)
}

View file

@ -0,0 +1,80 @@
---
import type { CollectionEntry } from 'astro:content';
import { Picture } from 'astro:assets';
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
interface Props {
item: MediaItem;
}
const { item } = Astro.props;
const videoWidth = item.type === 'video' ? (item.poster?.width ?? 1280) : undefined;
const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undefined;
---
<figure class="post-media">
{
item.type === 'video' ? (
// Decorative videos stay inert and hidden from assistive tech. Meaningful
// videos expose controls, captions, and an accessible name.
item.decorative ? (
<video
muted
playsinline
preload="metadata"
poster={item.poster?.src}
width={videoWidth}
height={videoHeight}
aria-hidden="true"
tabindex="-1"
>
{item.webm && <source src={item.webm} type="video/webm" />}
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
</video>
) : (
<video
controls
preload="none"
poster={item.poster?.src}
width={videoWidth}
height={videoHeight}
aria-label={item.alt}
>
{item.webm && <source src={item.webm} type="video/webm" />}
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
{item.captions && (
<track
kind="captions"
src={item.captions}
srclang="en"
label={item.captionsLabel}
default
/>
)}
</video>
)
) : (
item.src && (
<Picture
src={item.src}
alt={item.decorative ? '' : (item.alt ?? '')}
formats={['avif', 'webp']}
widths={[480, 720, 960, 1280, 1600, 1920]}
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
loading="lazy"
decoding="async"
/>
)
)
}
{item.caption && !item.decorative && <figcaption>{item.caption}</figcaption>}
{
item.transcript && (
<p class="media-transcript">
<strong>Transcript:</strong> {item.transcript}
</p>
)
}
</figure>

View file

@ -0,0 +1,64 @@
---
import type { CollectionEntry } from 'astro:content';
type Link = CollectionEntry<'projects'>['data']['links'][number];
interface Props {
links: Link[];
}
const { links } = Astro.props;
function isExternal(url: string) {
return /^https?:\/\//.test(url);
}
---
{
links.length > 0 && (
<ul class="project-links">
{links.map((link) => (
<li>
<a
href={link.url}
download={link.download ? '' : undefined}
rel={isExternal(link.url) ? 'noopener noreferrer' : undefined}
target={isExternal(link.url) ? '_blank' : undefined}
>
{link.label}
{isExternal(link.url) && (
<>
<svg
class="external-link-icon"
xmlns="http://www.w3.org/2000/svg"
width="0.85em"
height="0.85em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
<span class="sr-only">(opens in new tab)</span>
</>
)}
{link.download && (
<>
<span class="download-indicator" aria-hidden="true">
</span>
<span class="sr-only">(download)</span>
</>
)}
</a>
</li>
))}
</ul>
)
}

View file

@ -0,0 +1,69 @@
---
import type { CollectionEntry } from 'astro:content';
import { getEntry } from 'astro:content';
import EntryThumbnail from './EntryThumbnail.astro';
import ProjectLinks from './ProjectLinks.astro';
import { PROJECT_THUMBNAIL, articlePath, entrySlug } from '../lib/site';
interface Props {
projects: CollectionEntry<'projects'>[];
// Opt-in: eagerly load the first thumbnail. Only set when the list is
// reliably above the fold. The home and projects-index lists sit below
// other sections, so leave this off there.
eagerFirstThumbnail?: boolean;
}
const { projects, eagerFirstThumbnail = false } = Astro.props;
// The `essay` field is a `reference('posts')`, so when present it's always a
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
const essayHrefs = new Map<string, string>();
for (const project of projects) {
const essay = project.data.essay;
if (!essay) continue;
const resolved = await getEntry(essay);
if (resolved) essayHrefs.set(project.id, articlePath(resolved));
}
---
<ol class="project-list">
{
projects.map((project, index) => {
const anchor = entrySlug(project);
const titleId = `${anchor}-title`;
const essayHref = essayHrefs.get(project.id);
const primaryHref = essayHref ?? project.data.links[0]?.url;
return (
<li class="project-card" id={anchor}>
<EntryThumbnail
src={project.data.thumbnail.src}
alt={project.data.thumbnail.alt}
href={primaryHref}
class="project-thumbnail"
widths={PROJECT_THUMBNAIL.widths}
sizes={PROJECT_THUMBNAIL.sizes}
ariaLabel={`Open project: ${project.data.title}`}
loading={eagerFirstThumbnail && index === 0 ? 'eager' : 'lazy'}
fetchpriority={eagerFirstThumbnail && index === 0 ? 'high' : undefined}
/>
<article class="project-card__summary">
<h3 id={titleId}>
{primaryHref ? (
<a href={primaryHref}>{project.data.title}</a>
) : (
project.data.title
)}
{essayHref && <span class="project-essay-badge">Article</span>}
</h3>
<p class="project-description">{project.data.description}</p>
<p class="project-meta">
{project.data.period} · {project.data.technologies.join(', ')}
</p>
{project.data.links.length > 0 && <ProjectLinks links={project.data.links} />}
</article>
</li>
);
})
}
</ol>

View file

@ -0,0 +1,40 @@
---
import { tagPath } from '../lib/site';
interface Props {
tags: readonly string[];
currentTag?: string;
limit?: number;
counts?: Record<string, number>;
}
const { tags, currentTag, limit, counts } = Astro.props;
const visibleTags = typeof limit === 'number' ? tags.slice(0, limit) : tags;
const remaining =
typeof limit === 'number' && tags.length > limit ? tags.length - limit : 0;
---
<ul class="tag-list">
{
visibleTags.map((tag) => (
<li>
<a href={tagPath(tag)} aria-current={tag === currentTag ? 'page' : undefined}>
{tag}
{counts && counts[tag] !== undefined && (
<span class="tag-count">{counts[tag]}</span>
)}
</a>
</li>
))
}
{
remaining > 0 && (
<li>
<a href="/tags/" class="tag-more">
+{remaining} more
</a>
</li>
)
}
</ul>

View file

@ -1,18 +0,0 @@
let isSpaceClickActive = false;
export const addSupportForTabNavigation = () =>
document.addEventListener('keydown', (e) => {
if (e.key === ' ') {
isSpaceClickActive = true;
(document.activeElement as HTMLElement)?.click();
e.preventDefault();
}
});
export const removeUnnecessaryOutlines = () =>
document.addEventListener('click', () => {
if (!isSpaceClickActive) {
(document.activeElement as HTMLElement).blur?.();
}
isSpaceClickActive = false;
});

View file

@ -1,12 +0,0 @@
export const getHeight = (e: HTMLElement): number => {
// ignores margin collapse
const computedStyle = getComputedStyle(e);
return (
e.clientHeight +
parseFloat(computedStyle.marginTop) +
parseFloat(computedStyle.marginBottom) +
parseFloat(computedStyle.borderTopWidth) +
parseFloat(computedStyle.borderBottomWidth)
);
};

View file

@ -1,2 +0,0 @@
export const mix = (from: number, to: number, q: number): number =>
from + (to - from) * q;

View file

@ -1,19 +0,0 @@
export class Random {
// don't set the seed 0
public constructor(public seed: number = 42) {}
public get next(): number {
// result is in [0, 1)
this.seed = Math.imul(48271, this.seed);
return ((2 ** 31 - 1) & this.seed) / 2 ** 31;
}
public choose<T>(list: Array<T>): T {
return list[Math.floor(this.inInterval(0, list.length))];
}
public inInterval(aClosed: number, bOpen: number): number {
return (bOpen - aClosed) * this.next + aClosed;
}
}

View file

@ -1,6 +0,0 @@
export const scrollToFragment = () => {
// it might be necessary when the page takes too long to load
if (location.hash) {
document.getElementById(location.hash.slice(1))?.scrollIntoView();
}
};

View file

@ -1,3 +0,0 @@
export const sum = (list: ArrayLike<number>): number =>
// @ts-ignore
Array.prototype.reduce.call(list, (a: number, sum: number) => a + sum, 0);

View file

@ -1,5 +0,0 @@
export const titleToFragment = (title: string): string =>
'#' +
encodeURIComponent(
title.toLocaleLowerCase().replace(/&.*?;/g, '').replace(/\W+/g, '-')
);

View file

@ -1,37 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta property="og:title" content="Portfolio | Andras Schmelczer" />
<meta property="og:description" content="Discover my projects." />
<meta property="og:url" content="https://schmelczer.dev" />
<meta property="og:image:width" content="1920" />
<meta property="og:image:height" content="1920" />
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" href="favicon.ico" type="image/x-icon" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta
name="description"
content="I'm Andras Schmelczer, and this is my portfolio. Discover some of my projects. I'm passionate about solving challenging problems and designing large-scale systems, especially in the context of machine learning."
/>
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<meta name="theme-color" content="#b7455e" />
<title>Portfolio | Andras Schmelczer</title>
<link inline inline-asset="index.css" inline-asset-delete />
</head>
<body>
<noscript>JavaScript is required for this website.</noscript>
<script inline inline-asset="index.js" inline-asset-delete></script>
</body>
</html>

View file

@ -1,105 +0,0 @@
@use 'style/fonts';
@use 'style/vars';
@use 'style/mixins' as *;
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
@media (prefers-reduced-motion) {
transition: none !important;
animation: none !important;
}
}
html[animations='off'] {
&,
*,
*::before,
*::after {
transition: none !important;
animation: none !important;
}
}
html {
height: 100%;
overflow: hidden;
-webkit-font-smooth: antialiased;
@media (min-width: 1000px) and (max-width: 1440px) {
font-size: 0.875rem;
}
@media (max-width: 999px) {
font-size: 0.8rem;
}
@media print {
& {
font-size: 0.7rem;
}
}
}
body {
background-color: var(--background);
transition: background-color linear var(--transition-time);
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
env(safe-area-inset-left);
height: 100%;
@media print {
& {
height: auto;
}
}
}
noscript {
@include square(100%);
@include center-children();
@include sub-title-font();
}
img,
video,
iframe {
user-select: none;
-webkit-user-select: none;
}
button {
border: none;
background: none;
}
svg {
stroke: var(--normal-text-color);
}
p {
@include main-font();
}
a {
text-decoration: none;
}
:focus {
outline: none;
&:not(:hover) {
outline: var(--accent-color) solid var(--line-width);
}
}
::selection {
background-color: var(--accent-color);
color: var(--very-light-text-color);
}

View file

@ -1,33 +0,0 @@
import '../static/no-change/404.html';
import '../static/no-change/favicons/android-chrome-192x192.png';
import '../static/no-change/favicons/android-chrome-512x512.png';
import '../static/no-change/favicons/apple-touch-icon.png';
import '../static/no-change/favicons/favicon-16x16.png';
import '../static/no-change/favicons/favicon-32x32.png';
import '../static/no-change/favicons/favicon.ico';
import '../static/no-change/favicons/site.webmanifest';
import '../static/no-change/og-image.jpg';
import '../static/no-change/robots.txt';
import { init as plausibleInit } from '@plausible-analytics/tracker';
import { portfolio } from './data/portfolio';
import {
addSupportForTabNavigation,
removeUnnecessaryOutlines,
} from './helper/accessibility';
import { scrollToFragment } from './helper/scroll-to-fragment';
import './index.scss';
plausibleInit({
domain: 'schmelczer.dev',
endpoint: 'https://stats.schmelczer.dev/status',
autoCapturePageviews: true,
captureOnLocalhost: true,
logging: true,
fileDownloads: true,
hashBasedRouting: true,
});
addSupportForTabNavigation();
removeUnnecessaryOutlines();
portfolio.forEach((e) => e.attachToDOM(document.body));
scrollToFragment();

178
src/layouts/Base.astro Normal file
View file

@ -0,0 +1,178 @@
---
import Footer from '../components/Footer.astro';
import Header from '../components/Header.astro';
import { absoluteUrl, optimizeOgImage, site } from '../lib/site';
import defaultOg from '../assets/og-default.jpg';
import themeInit from '../scripts/theme-init.js?raw';
import '../styles/global.css';
interface ArticleMeta {
publishedTime: string;
modifiedTime?: string;
tags?: readonly string[];
}
interface Props {
title?: string;
description?: string;
canonicalPath?: string;
ogImage?: string;
ogImageAlt?: string;
ogImageWidth?: number;
ogImageHeight?: number;
ogType?: 'website' | 'article' | 'profile';
article?: ArticleMeta;
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
noindex?: boolean;
preloadMono?: boolean;
}
const {
title = site.title,
description = site.description,
canonicalPath: rawCanonicalPath = Astro.url.pathname,
ogImage,
ogImageAlt = "Andras Schmelczer's personal site",
ogImageWidth,
ogImageHeight,
ogType = 'website',
article,
jsonLd,
noindex = false,
preloadMono = false,
} = Astro.props;
const isRoot = title === site.title;
const pageTitle = isRoot ? site.title : `${title} · ${site.name}`;
const ogTitle = isRoot ? site.title : title;
const canonicalPath =
rawCanonicalPath === '/' ||
rawCanonicalPath.endsWith('/') ||
/\.[^/]+$/.test(rawCanonicalPath)
? rawCanonicalPath
: `${rawCanonicalPath}/`;
const canonical = absoluteUrl(canonicalPath);
let resolvedOgImage = ogImage;
let resolvedOgWidth = ogImageWidth ?? 1200;
let resolvedOgHeight = ogImageHeight ?? 630;
if (!resolvedOgImage) {
const generated = await optimizeOgImage(defaultOg);
resolvedOgImage = generated.src;
resolvedOgWidth = 1200;
resolvedOgHeight = 630;
}
const ogImageUrl = resolvedOgImage.startsWith('http')
? resolvedOgImage
: absoluteUrl(resolvedOgImage);
const ogImageExt = ogImageUrl
.match(/\.(png|webp|gif|svg|jpe?g)(?:\?|$)/i)?.[1]
?.toLowerCase();
const ogImageType =
ogImageExt === 'png'
? 'image/png'
: ogImageExt === 'webp'
? 'image/webp'
: ogImageExt === 'gif'
? 'image/gif'
: ogImageExt === 'svg'
? 'image/svg+xml'
: 'image/jpeg';
const jsonLdEntries = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
const jsonLdStrings = jsonLdEntries.map((entry) =>
JSON.stringify(entry).replace(/</g, '\\u003c')
);
---
<!doctype html>
<html lang="en" class="no-js">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<title>{pageTitle}</title>
<meta name="description" content={description} />
<meta name="author" content={site.name} />
<meta name="color-scheme" content="light dark" />
{noindex && <meta name="robots" content="noindex,follow" />}
{!noindex && <link rel="canonical" href={canonical} />}
<meta name="theme-color" content="#fbfaf7" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#151514" media="(prefers-color-scheme: dark)" />
<script is:inline data-theme-script set:html={themeInit} />
<link
rel="preload"
href="/fonts/source-sans-3-latin-variable.woff2"
as="font"
type="font/woff2"
crossorigin
/>
{
preloadMono && (
<link
rel="preload"
href="/fonts/ibm-plex-mono-latin-400.woff2"
as="font"
type="font/woff2"
crossorigin
/>
)
}
<link
rel="alternate"
type="application/rss+xml"
title={`${site.name} RSS`}
href="/rss.xml"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta property="og:site_name" content={site.name} />
<meta property="og:title" content={ogTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImageUrl} />
<meta property="og:image:type" content={ogImageType} />
<meta property="og:image:alt" content={ogImageAlt} />
<meta property="og:image:width" content={String(resolvedOgWidth)} />
<meta property="og:image:height" content={String(resolvedOgHeight)} />
<meta property="og:type" content={ogType} />
<meta property="og:locale" content="en_US" />
{
article && (
<>
<meta property="article:published_time" content={article.publishedTime} />
{article.modifiedTime && (
<meta property="article:modified_time" content={article.modifiedTime} />
)}
<meta property="article:author" content={absoluteUrl('/about/')} />
{article.tags?.map((tag) => (
<meta property="article:tag" content={tag} />
))}
</>
)
}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={ogTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImageUrl} />
<meta name="twitter:image:alt" content={ogImageAlt} />
{
jsonLdStrings.map((jsonLdString) => (
<script is:inline type="application/ld+json" set:html={jsonLdString} />
))
}
</head>
<body>
<Header />
<main id="content" tabindex="-1">
<slot />
</main>
<Footer />
</body>
</html>

22
src/layouts/Page.astro Normal file
View file

@ -0,0 +1,22 @@
---
import type { ComponentProps } from 'astro/types';
import Base from './Base.astro';
type Props = Omit<ComponentProps<typeof Base>, 'title'> & { title: string };
const { title, description } = Astro.props;
if (!title) {
throw new Error('Page layout requires a `title` prop.');
}
---
<Base {...Astro.props}>
<div class="page-shell">
<header class="page-header">
<slot name="breadcrumbs" />
<h1>{title}</h1>
{description && <p>{description}</p>}
</header>
<slot />
</div>
</Base>

207
src/layouts/Post.astro Normal file
View file

@ -0,0 +1,207 @@
---
import type { CollectionEntry } from 'astro:content';
import { render } from 'astro:content';
import { Picture } from 'astro:assets';
import ArticleList from '../components/ArticleList.astro';
import AtAGlance from '../components/AtAGlance.astro';
import Breadcrumbs from '../components/Breadcrumbs.astro';
import PostMedia from '../components/PostMedia.astro';
import TagList from '../components/TagList.astro';
import {
absoluteUrl,
adjacentPosts,
articlePath,
buildBreadcrumbJsonLd,
buildPersonJsonLd,
buildBreadcrumbTrail,
formatDate,
getPublishedPosts,
getRelatedPosts,
optimizeOgImage,
} from '../lib/site';
import Base from './Base.astro';
interface Props {
post: CollectionEntry<'posts'>;
}
const { post } = Astro.props;
const { Content, headings } = await render(post);
const allPosts = await getPublishedPosts();
const { previous, next } = adjacentPosts(allPosts, post);
const related = getRelatedPosts(allPosts, post, 3);
const ogImageOptimized = await optimizeOgImage(post.data.thumbnail.src);
const trail = buildBreadcrumbTrail({ post });
const breadcrumbTrail = trail.map((c, i) => ({
label: c.name,
href: i === trail.length - 1 ? undefined : c.href,
}));
// Reading time: words in body / 200 wpm, rounded up.
const wordCount = post.body ? post.body.trim().split(/\s+/).filter(Boolean).length : 0;
const readingMinutes = Math.max(1, Math.ceil(wordCount / 200));
// Only preload the monospace font if the post body actually contains code
// (inline `…` or fenced ``` blocks). Saves ~15 KB on every code-free article.
const hasCode = !!post.body && /(^|[^`])`[^`\n]+`|```/m.test(post.body);
// TOC: only show when there are >= 3 h2 headings.
const h2Headings = headings.filter((h) => h.depth === 2);
const showToc = h2Headings.length >= 3;
const personId = absoluteUrl('/about/#person');
const blogPosting = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.data.title,
description: post.data.description,
datePublished: post.data.date.toISOString(),
...(post.data.updated && { dateModified: post.data.updated.toISOString() }),
author: { '@id': personId },
publisher: { '@id': personId },
image: absoluteUrl(ogImageOptimized.src),
url: absoluteUrl(articlePath(post)),
keywords: post.data.tags.join(', '),
mainEntityOfPage: {
'@type': 'WebPage',
'@id': absoluteUrl(articlePath(post)),
},
};
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
const personJsonLd = buildPersonJsonLd();
---
<Base
title={post.data.title}
description={post.data.description}
canonicalPath={articlePath(post)}
ogImage={ogImageOptimized.src}
ogImageAlt={post.data.thumbnail.alt}
ogImageWidth={1200}
ogImageHeight={630}
ogType="article"
preloadMono={hasCode}
article={{
publishedTime: post.data.date.toISOString(),
modifiedTime: post.data.updated?.toISOString(),
tags: post.data.tags,
}}
jsonLd={[blogPosting, breadcrumbJsonLd, personJsonLd]}
>
<article class="post">
<header class="post-header">
<Breadcrumbs items={breadcrumbTrail} />
<p class="eyebrow">{post.data.projectPeriod ?? 'Article'}</p>
<h1>{post.data.title}</h1>
<p class="dek">{post.data.description}</p>
<div class="post-meta">
<time datetime={post.data.date.toISOString()}>
{formatDate(post.data.date)}
</time>
{
post.data.updated && (
<>
{' · '}
<span>
Updated{' '}
<time datetime={post.data.updated.toISOString()}>
{formatDate(post.data.updated)}
</time>
</span>
</>
)
}
{' · '}
<span>{readingMinutes} min read</span>
</div>
<TagList tags={post.data.tags} />
</header>
<div class="post-thumbnail">
<Picture
src={post.data.thumbnail.src}
alt={post.data.thumbnail.alt}
formats={['avif', 'webp']}
fallbackFormat="jpg"
widths={[640, 960, 1280, 1600, 1920]}
sizes="(max-width: 700px) calc(100vw - 3rem), (max-width: 1100px) calc(100vw - 4rem), 56rem"
loading="eager"
fetchpriority="high"
decoding="async"
/>
</div>
<AtAGlance
headingId={`at-a-glance-${post.id}`}
role={post.data.role}
projectPeriod={post.data.projectPeriod}
stack={post.data.stack}
scale={post.data.scale}
outcome={post.data.outcome}
links={post.data.links}
/>
{
showToc && (
<nav class="post-toc" aria-label="On this page">
<ol>
{h2Headings.map((heading) => (
<li>
<a href={`#${heading.slug}`}>{heading.text}</a>
</li>
))}
</ol>
</nav>
)
}
<div class="prose">
<Content />
</div>
<PostMedia items={post.data.media} />
{
related.length > 0 && (
<section class="related-posts">
<h2>Related articles</h2>
<ArticleList posts={related} />
</section>
)
}
{
(previous || next) && (
<nav class="post-nav" aria-label="Adjacent articles">
<ul class="post-nav__list">
{previous && (
<li class="post-nav__prev">
<a class="previous" href={articlePath(previous)} rel="prev">
<span class="post-nav__label">
<span aria-hidden="true">←</span> Previous
</span>
<span class="post-nav__title">{previous.data.title}</span>
</a>
</li>
)}
{next && (
<li class="post-nav__next">
<a class="next" href={articlePath(next)} rel="next">
<span class="post-nav__label">
Next <span aria-hidden="true">→</span>
</span>
<span class="post-nav__title">{next.data.title}</span>
</a>
</li>
)}
</ul>
</nav>
)
}
</article>
</Base>

235
src/lib/site.ts Normal file
View file

@ -0,0 +1,235 @@
import type { CollectionEntry } from 'astro:content';
import { getCollection } from 'astro:content';
import { getImage } from 'astro:assets';
import type { ImageMetadata } from 'astro';
export const site = {
name: 'Andras Schmelczer',
title: 'Andras Schmelczer — Software engineer',
description:
'Andras Schmelczer writes about software systems, AI deployment, graphics, simulations, and tools.',
url: 'https://schmelczer.dev',
email: 'andras@schmelczer.dev',
github: 'https://github.com/schmelczer',
linkedin: 'https://www.linkedin.com/in/andras-schmelczer',
cv: '/media/downloads/cv-andras-schmelczer.pdf',
};
// Single source of truth for primary navigation. The Header consumes every
// entry where `footerOnly` is falsy AND `href !== '/'` (Home is implicit via
// the site title). The Footer renders every entry regardless. Items marked
// `footerOnly: true` appear only in the Footer.
export interface NavItem {
href: string;
label: string;
footerOnly?: boolean;
}
export const navItems: readonly NavItem[] = [
{ href: '/', label: 'Home' },
{ href: '/articles/', label: 'Articles' },
{ href: '/projects/', label: 'Projects' },
{ href: '/about/', label: 'About' },
{ href: '/tags/', label: 'Tags' },
{ href: '/rss.xml', label: 'RSS', footerOnly: true },
];
export function formatDate(date: Date) {
return new Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
}
export function formatDateShort(date: Date) {
return new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
}).format(date);
}
export function yearOf(date: Date) {
return new Intl.DateTimeFormat('en', { year: 'numeric' }).format(date);
}
export function entrySlug(entry: { id: string }) {
return entry.id.replace(/\.mdx?$/, '').replace(/\/index$/, '');
}
export function articlePath(entry: { id: string } | string) {
const slug = typeof entry === 'string' ? entry : entrySlug(entry);
return `/articles/${slug}/`;
}
export function tagSlug(tag: string) {
return tag
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
export function tagPath(tag: string) {
return `/tags/${tagSlug(tag)}/`;
}
export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) =>
a.localeCompare(b)
);
}
// Memoized published-posts loader. Build steps call `getPublishedPosts()`
// from many pages (index, articles, RSS, sitemap, tag pages, post layouts).
// Caching the promise means `getCollection('posts')` runs once per build.
let publishedPostsPromise: Promise<CollectionEntry<'posts'>[]> | undefined;
export function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
if (!publishedPostsPromise) {
publishedPostsPromise = getCollection('posts').then((posts) =>
posts
.filter((post) => !post.data.draft)
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
);
}
return publishedPostsPromise;
}
export async function getProjects(): Promise<CollectionEntry<'projects'>[]> {
return (await getCollection('projects')).sort(
(a, b) => b.data.sortDate.valueOf() - a.data.sortDate.valueOf()
);
}
export function adjacentPosts(
posts: CollectionEntry<'posts'>[],
current: CollectionEntry<'posts'>
) {
const index = posts.findIndex((post) => post.id === current.id);
if (index === -1) return { previous: undefined, next: undefined };
return {
previous: index < posts.length - 1 ? posts[index + 1] : undefined,
next: index > 0 ? posts[index - 1] : undefined,
};
}
export function getRelatedPosts(
posts: CollectionEntry<'posts'>[],
current: CollectionEntry<'posts'>,
limit = 3
) {
const currentTags = new Set(current.data.tags);
return posts
.filter((post) => post.id !== current.id)
.map((post) => ({
post,
overlap: post.data.tags.filter((tag) => currentTags.has(tag)).length,
}))
.filter(({ overlap }) => overlap > 0)
.sort((a, b) => b.overlap - a.overlap)
.slice(0, limit)
.map(({ post }) => post);
}
export function absoluteUrl(path: string) {
return new URL(path, site.url).toString();
}
// Canonical Person JSON-LD. Used by the home page and About page; both share
// `@id` so search engines treat them as the same entity. Pass `extra` to
// add or override fields (e.g. `jobTitle`, richer `description`).
export function buildPersonJsonLd(extra?: Record<string, unknown>) {
return {
'@context': 'https://schema.org',
'@type': 'Person',
'@id': absoluteUrl('/about/#person'),
name: site.name,
url: site.url,
email: `mailto:${site.email}`,
sameAs: [site.github, site.linkedin],
description: site.description,
...extra,
};
}
// Responsive image config shared by entry listings. Centralized here so a
// change to one breakpoint set is a single edit, not two component changes.
export const ARTICLE_THUMBNAIL = {
widths: [120, 180, 240, 320, 480],
sizes: '(max-width: 700px) clamp(64px, 22vw, 80px), (max-width: 960px) 7rem, 8rem',
};
export const PROJECT_THUMBNAIL = {
widths: [240, 320, 480, 640, 800],
sizes: '(max-width: 700px) 7rem, (max-width: 960px) clamp(7rem, 18vw, 9.5rem), 19rem',
};
// Wraps `getImage` with the standard OG dimensions (1200x630 JPEG). Used by
// Base.astro for the default OG image and by Post.astro for per-post
// thumbnails. Keeps OG output consistent across the site.
export function optimizeOgImage(src: ImageMetadata) {
return getImage({
src,
width: 1200,
height: 630,
format: 'jpg',
});
}
interface BreadcrumbCrumb {
name: string;
href: string;
}
interface BreadcrumbInput {
articles?: boolean;
projects?: boolean;
tagsIndex?: boolean;
tag?: string;
post?: CollectionEntry<'posts'>;
}
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the
// visible Breadcrumbs component. Home is always first. Flags append crumbs
// in a fixed order: Articles → Tags → tag → Post (or Projects). A `tag`
// implies both Articles and Tags so callers don't have to set every flag.
export function buildBreadcrumbTrail({
articles,
projects,
tagsIndex,
tag,
post,
}: BreadcrumbInput): BreadcrumbCrumb[] {
const trail: BreadcrumbCrumb[] = [{ name: 'Home', href: '/' }];
if (articles || post || tagsIndex || tag) {
trail.push({ name: 'Articles', href: '/articles/' });
}
if (tagsIndex || tag) {
trail.push({ name: 'Tags', href: '/tags/' });
}
if (tag) {
trail.push({ name: `#${tag}`, href: tagPath(tag) });
}
if (post) {
trail.push({ name: post.data.title, href: articlePath(post) });
}
if (projects) {
trail.push({ name: 'Projects', href: '/projects/' });
}
return trail;
}
// Builds the schema.org BreadcrumbList JSON-LD object for a given trail.
// Shared by every page that emits breadcrumb structured data.
export function buildBreadcrumbJsonLd(trail: BreadcrumbCrumb[]) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: trail.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: absoluteUrl(crumb.href),
})),
};
}

View file

@ -1,33 +0,0 @@
import { html } from '../../types/html';
import './contact.scss';
// @ts-ignore: injected by webpack
const LAST_EDIT = new Date(__CURRENT_DATE__);
export const Contact = ({
title,
links,
lastEditText,
}: {
title: string;
links: Array<html>;
lastEditText: string;
}): html => `
<section id="contact">
<h2>
<a href="#contact">${title}</a>
</h2>
<div class="links">
${links.join('')}
</div>
<aside>
<p>
${lastEditText}
<time datetime="${LAST_EDIT.toISOString()}">${LAST_EDIT.toLocaleDateString()}</time>
</p>
</aside>
</section>
`;

View file

@ -1,30 +0,0 @@
@use '../../style/mixins' as *;
#contact {
text-align: center;
margin: var(--large-margin) auto 0 auto;
> h2 {
margin-bottom: var(--normal-margin);
> a {
@include title-font();
@include title-fragment-link();
}
}
> .links {
display: inline-block;
}
> aside {
margin: var(--large-margin) auto 0 auto;
padding-bottom: var(--line-height); // margin-bottom doesn't work in Firefox
> p {
@include special-text-font();
color: var(--normal-text-color);
opacity: 0.75;
}
}
}

View file

@ -1,23 +0,0 @@
import { ResponsiveImage } from '../../../types/responsive-image';
import { ImageViewer } from '../../image-viewer/image-viewer';
import { Image } from '../../image/image.html';
import { Figure } from '../figure';
import './bordered-image.scss';
export class BorderedImage extends Figure {
public constructor(
options: {
image: ResponsiveImage;
alt: string;
sizes?: string | null;
isEagerLoaded?: boolean;
},
public imageViewer?: ImageViewer
) {
super(Image(options));
}
protected async onClick() {
this.imageViewer?.showImage(this.query('img') as HTMLImageElement);
}
}

View file

@ -1,21 +0,0 @@
import play from '../../../static/icons/play-button.svg';
import { html } from '../../types/html';
import './figure.scss';
export const generate = ({
children,
hasButton,
invertButton,
}: {
children: html;
hasButton: boolean;
invertButton: boolean;
}): html => `
<div class="figure-container" tabindex=0 >
${children}
${
hasButton
? `<div class="start-button ${invertButton ? 'inverted' : ''}" >${play}</div>`
: ''
}
</div>`;

View file

@ -1,33 +0,0 @@
@use '../../style/mixins' as *;
.figure-container {
box-shadow: var(--inset-shadow);
position: relative;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
> .start-button {
@include image-button(var(--large-icon-size));
@include absolute-center;
@include square(calc(var(--large-icon-size) + var(--normal-margin) * 2));
&:hover > svg {
box-shadow: var(--shadow);
}
> svg {
border-radius: 1000px;
@include blurred-background;
transition:
transform var(--transition-time),
box-shadow var(--transition-time);
}
&.inverted > svg {
fill: var(--accent-color);
}
}
}

View file

@ -1,21 +0,0 @@
import { html } from '../../types/html';
import { PageElement } from '../page-element';
import { generate } from './figure.html';
export abstract class Figure extends PageElement {
public constructor(
children: html,
{
hasButton = false,
invertButton = false,
}: {
hasButton?: boolean;
invertButton?: boolean;
} = {}
) {
super(generate({ children, hasButton, invertButton }));
this.htmlRoot.addEventListener('click', this.onClick.bind(this));
}
protected abstract onClick(): unknown;
}

View file

@ -1,21 +0,0 @@
import loading from '../../../../static/icons/loading.svg';
import { html } from '../../../types/html';
import { ResponsiveImage } from '../../../types/responsive-image';
import { Image } from '../../image/image.html';
import './preview.scss';
export const generate = ({
alt,
poster,
}: {
alt: string;
poster: ResponsiveImage;
}): html =>
`${Image({
image: poster,
alt,
})}
<div class="overlay">
<div class="loading">${loading}</div>
<iframe title="${alt}" allowfullscreen loading="lazy"></iframe>
</div>`;

View file

@ -1,38 +0,0 @@
@use '../../../style/mixins' as *;
.figure-container {
> .overlay {
@include square(100%);
position: absolute;
left: 0;
top: 0;
pointer-events: none;
> .loading {
@include square(var(--large-icon-size));
@include absolute-center;
visibility: hidden;
}
> iframe {
@include square(100%);
border: none;
position: absolute;
left: 0;
}
}
&.loaded {
> .start-button {
visibility: hidden;
}
> .overlay {
pointer-events: all;
> .loading {
visibility: visible;
}
}
}
}

View file

@ -1,32 +0,0 @@
import { ResponsiveImage } from '../../../types/responsive-image';
import { Figure } from '../figure';
import { generate } from './preview.html';
export class Preview extends Figure {
public constructor(
poster: ResponsiveImage,
private readonly url: string,
alt: string
) {
super(generate({ poster, alt }), {
hasButton: true,
});
this.url += '?portfolioView';
}
protected onClick() {
this.htmlRoot.classList.add('loaded');
(this.query('iframe') as HTMLIFrameElement).src = this.url;
}
protected initialize() {
new IntersectionObserver((e) => {
if (!e[0].isIntersecting) {
this.htmlRoot.classList.remove('loaded');
(this.query('iframe') as HTMLIFrameElement).src = '';
}
}).observe(this.htmlRoot.parentElement!);
super.initialize();
}
}

View file

@ -1,10 +0,0 @@
import { ResponsiveImage } from '../../../types/responsive-image';
import { url } from '../../../types/url';
export interface VideoParameters {
mp4: url;
webm: url;
poster: ResponsiveImage;
altText: string;
invertButton?: boolean;
}

View file

@ -1,14 +0,0 @@
import { html } from '../../../types/html';
import { Image } from '../../image/image.html';
import { VideoParameters } from './video-parameters';
import './video.scss';
export const generate = ({ webm, mp4, poster, altText }: VideoParameters): html => `
${Image({
image: poster,
alt: altText,
})}
<video playsinline controls preload="none">
<source src="${webm}" type="video/webm"/>
<source src="${mp4}" type="video/mp4"/>
</video>`;

View file

@ -1,16 +0,0 @@
@use '../../../style/mixins' as *;
.figure-container {
&.loaded > .start-button,
&:not(.loaded) > video {
visibility: hidden;
}
> video {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
}

View file

@ -1,19 +0,0 @@
import { Figure } from '../figure';
import { VideoParameters } from './video-parameters';
import { generate } from './video.html';
export class Video extends Figure {
public constructor(options: VideoParameters) {
super(generate(options), {
hasButton: true,
invertButton: options.invertButton,
});
}
protected async onClick() {
this.query('.start-button').style.visibility = 'hidden';
this.htmlRoot.classList.add('loaded');
await (this.query('video') as HTMLVideoElement).play();
}
}

View file

@ -1,21 +0,0 @@
import { html } from '../../types/html';
import './header.scss';
export const generate = ({
name,
about,
}: {
name: string;
about: Array<string>;
}): html => `
<header id="about">
<div class="profile-picture">
<img/>
<div class="placeholder"></div>
</div>
<h1>${name}</h1>
${about.map((t) => `<p>${t}</p>`).join('')}
</header>
`;

View file

@ -1,95 +0,0 @@
@use 'sass:math';
@use '../../style/mixins' as *;
#about {
box-shadow: var(--shadow);
padding: var(--normal-margin);
background-color: var(--accent-color);
@include on-small-screen {
:focus:not(:hover) {
outline: var(--very-light-text-color) solid var(--line-width);
}
$img-size: 11rem;
> .profile-picture {
@include square($img-size);
margin: auto;
}
> h1 {
text-align: center;
margin-top: calc(var(--line-height) + 4px);
}
}
@include on-large-screen {
$img-size: 12.5rem;
width: var(--body-width);
margin: calc(#{var(--normal-margin)} + #{$img-size} * 1 / 3) auto var(--large-margin)
auto;
border-radius: var(--border-radius);
> .profile-picture {
> .figure-container {
@include square($img-size);
position: absolute;
left: calc(#{math.div(-$img-size, 3)} - var(--normal-margin));
top: calc(#{math.div(-$img-size, 3)} - var(--normal-margin));
}
> .placeholder {
@include square(calc(#{$img-size} * 2 / 3 - #{var(--normal-margin)}));
box-sizing: content-box;
float: left;
margin: 0 0.75ex 0.2ex 0;
}
}
> p {
text-align: justify;
}
}
::selection {
background-color: var(--very-light-text-color);
color: var(--accent-color);
}
> h1,
> .profile-picture > .placeholder {
@include title-font();
}
> .profile-picture {
position: relative;
z-index: 1;
.figure-container {
&,
> .image {
border-radius: 100%;
}
}
}
> h1,
> p,
a {
color: var(--very-light-text-color);
:focus:not(:hover) {
outline: var(--very-light-text-color) solid var(--line-width);
}
}
> p {
margin-top: var(--line-height);
}
a {
border-bottom: 2px solid var(--very-light-text-color);
}
}

View file

@ -1,42 +0,0 @@
import { ResponsiveImage } from '../../types/responsive-image';
import { BorderedImage } from '../figure/bordered-image/bordered-image';
import { ImageViewer } from '../image-viewer/image-viewer';
import { PageElement } from '../page-element';
import { generate } from './header.html';
import { ThemeSwitcher } from './theme-switcher/theme-switcher';
export class Header extends PageElement {
public constructor({
name,
image,
imageAltText,
about,
imageViewer,
}: {
name: string;
image: ResponsiveImage;
imageAltText: string;
about: Array<string>;
imageViewer?: ImageViewer;
}) {
super(
generate({
name,
about,
})
);
this.attachElementByReplacing(
'img',
new BorderedImage(
{
image,
alt: imageAltText,
sizes: '(max-width: 924px) 11rem, 12.5rem',
},
imageViewer
)
);
this.attachElement(new ThemeSwitcher());
}
}

View file

@ -1,6 +0,0 @@
import { html } from '../../../types/html';
import './theme-switcher.scss';
export const generate = (): html => `
<input id="theme-switcher" aria-label="color-theme-switch" type="checkbox" name="switch-theme"/>
`;

View file

@ -1,92 +0,0 @@
@use '../../../style/mixins' as *;
#theme-switcher {
cursor: pointer;
background-color: var(--accent-color);
-webkit-appearance: none;
-moz-appearance: none;
display: block;
$size: var(--icon-size);
width: calc(2 * #{$size});
height: $size;
$icon-size: calc(0.7 * #{$size});
$margin: calc((#{$size} - #{$icon-size}) / 2);
border-radius: 1000px;
box-shadow:
inset 0 0 10px 2px rgba(0, 0, 0, 0.175),
inset 0 0 1px rgba(0, 0, 0, 0.4);
@include on-large-screen {
position: absolute;
top: var(--normal-margin);
right: var(--normal-margin);
}
@include on-small-screen {
position: relative;
margin: var(--normal-margin) auto 0 auto;
}
&:before {
// moon + sun
@include square($icon-size);
}
&:after {
// sun blocking moon
@include square(calc(#{$icon-size} * 0.8));
}
&:before,
&:after {
content: '';
position: absolute;
display: block;
border-radius: 1000px;
top: 50%;
transform: translateY(-50%);
transition:
transform var(--transition-time),
background-color var(--transition-time);
}
&:not(:checked) {
&:before {
transform: translateY(-50%) translateX(calc(3 * #{$margin} + #{$icon-size}));
animation: shine 2s linear alternate infinite;
background-color: var(--sun-color);
@keyframes shine {
from {
filter: brightness(1.01);
box-shadow: 0 0 4px 2px var(--sun-color);
}
to {
filter: brightness(1.2);
box-shadow: 0 0 15px 2px var(--sun-color);
}
}
}
&:after {
transform: translateY(-50%) translateX(calc(#{$size} * 2 - #{$icon-size}));
}
}
&:checked {
&:before {
background-color: var(--normal-text-color);
transform: translateY(-50%) translateX($margin);
}
&:after {
background-color: var(--accent-color);
transform: translateY(-50%) translateX(calc(#{$margin} + #{$icon-size} * 0.33));
}
}
}

View file

@ -1,62 +0,0 @@
import { PageElement } from '../../page-element';
import { generate } from './theme-switcher.html';
export class ThemeSwitcher extends PageElement {
private static readonly localStorageKey = 'dark-mode';
public constructor() {
super(generate());
const storedIsDark = ThemeSwitcher.loadFromLocalStorage();
const isDark = storedIsDark ?? isSystemLevelDarkModeEnabled();
if (isDark) {
(this.htmlRoot as HTMLInputElement).checked = true;
turnOffAnimations();
turnOnDarkMode();
setTimeout(turnOnAnimations, 0);
} else {
turnOnLightMode();
}
this.htmlRoot.onchange = this.handleThemeChange.bind(this);
}
private handleThemeChange() {
const isDark = (this.htmlRoot as HTMLInputElement).checked;
if (isDark) {
turnOnDarkMode();
} else {
turnOnLightMode();
}
ThemeSwitcher.saveToLocalStorage(isDark);
}
private static saveToLocalStorage(darkModeEnabled: boolean) {
localStorage?.setItem(ThemeSwitcher.localStorageKey, JSON.stringify(darkModeEnabled));
}
private static loadFromLocalStorage(): boolean | null {
try {
return JSON.parse(localStorage!.getItem(ThemeSwitcher.localStorageKey)!);
} catch {
return null;
}
}
}
export const isSystemLevelDarkModeEnabled = (): boolean =>
matchMedia && matchMedia('(prefers-color-scheme: dark)').matches;
export const turnOnDarkMode = () =>
document.documentElement.setAttribute('theme', 'dark');
export const turnOnLightMode = () =>
document.documentElement.setAttribute('theme', 'light');
export const turnOnAnimations = () =>
document.documentElement.setAttribute('animations', 'on');
export const turnOffAnimations = () =>
document.documentElement.setAttribute('animations', 'off');

View file

@ -1,16 +0,0 @@
import { url } from '../../types/url';
import './image-anchor.scss';
export const ImageAnchorFactory =
(
svg: string,
title: string,
{ shouldDownload = false }: { shouldDownload?: boolean } = {}
) =>
(href: url) =>
`<a rel="noopener" target="_blank" href="${href}" ${
shouldDownload ? 'download' : ''
} class="image-anchor">
${svg}
<span>${title}</span>
</a>`;

View file

@ -1,20 +0,0 @@
@use '../../style/mixins' as *;
.image-anchor {
display: flex;
align-items: center;
&:not(:first-child) {
padding-top: var(--line-height);
}
svg {
@include square(var(--icon-size));
margin-right: calc(var(--small-margin) / 2);
}
span {
@include link;
font-size: 1.4rem;
}
}

View file

@ -1,23 +0,0 @@
import { url } from '../../types/url';
import './image-button.scss';
export const ImageButtonFactory =
(
svg: string,
title: string,
{ shouldDownload = false }: { shouldDownload?: boolean } = {}
) =>
(href?: url) =>
`
<button class="image-button">
${
href
? `<a href="${href}" tabindex="-1" rel="noopener" target="_blank" ${
shouldDownload ? 'download' : ''
}>`
: ''
}
<div class="svg-container">${svg}</div>
<p>${title}</p>
${href ? '</a>' : ''}
</button>`;

View file

@ -1,25 +0,0 @@
@use '../../style/mixins' as *;
.image-button {
@include image-button(var(--icon-size));
padding: var(--small-margin) 8px;
text-align: center;
.svg-container {
position: relative;
margin: auto;
@include square(var(--icon-size));
> svg {
transition:
stroke var(--transition-time),
transform var(--transition-time);
}
}
p {
font-size: 0.9rem;
font-style: italic;
text-align: center;
}
}

View file

@ -1,10 +0,0 @@
import cancel from '../../../static/icons/cancel.svg';
import { html } from '../../types/html';
import './image-viewer.scss';
export const generate = (): html => `
<div id="image-viewer">
<img height="0" width="0" />
<button id="cancel">${cancel}</button>
</div>
`;

View file

@ -1,37 +0,0 @@
@use '../../style/mixins' as *;
#image-viewer {
@include center-children();
@include blurred-background();
visibility: hidden;
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
img {
@include square(auto);
box-shadow: var(--shadow);
max-width: 80%;
max-height: 80%;
@include on-small-screen {
max-width: 95%;
max-height: 80%;
}
}
#cancel {
@include image-button(var(--large-icon-size));
@include square(calc(var(--large-icon-size) + var(--normal-margin) * 2));
position: absolute;
right: 0;
top: 0;
&:focus:not(:hover) {
outline: var(--very-light-text-color) solid var(--line-width);
}
}
}

View file

@ -1,29 +0,0 @@
import { PageElement } from '../page-element';
import { generate } from './image-viewer.html';
export class ImageViewer extends PageElement {
public constructor() {
super(generate());
document.body.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Escape') {
this.hideImage();
}
});
this.htmlRoot.addEventListener('click', this.hideImage.bind(this));
}
public showImage(source: HTMLImageElement) {
const image = this.query('img') as HTMLImageElement;
image.src = '';
image.src = source.src;
image.height = source.height;
image.width = source.width;
this.htmlRoot.style.visibility = 'visible';
}
private hideImage() {
this.htmlRoot.style.visibility = 'hidden';
}
}

View file

@ -1,29 +0,0 @@
import { html } from '../../types/html';
import { ResponsiveImage } from '../../types/responsive-image';
import './image.scss';
export const Image = ({
image,
alt,
sizes = null,
isEagerLoaded = false,
}: {
image: ResponsiveImage;
alt: string;
sizes?: string | null;
isEagerLoaded?: boolean;
}): html => `
<div
class="image"
style="background-size: cover; background-image: url('${
image.placeholder
}'); aspect-ratio: ${image.width / image.height};"
>
<img
${isEagerLoaded ? '' : 'loading="lazy"'}
srcset="${image.srcSet}"
${sizes ? `sizes="${sizes}"` : ''}
src="${image.src}"
alt="${alt}"
/>
</div>`;

View file

@ -1,10 +0,0 @@
.image {
overflow: hidden;
position: relative;
z-index: -1;
img {
max-width: 100%;
max-height: 100%;
}
}

View file

@ -1,5 +0,0 @@
import { url } from '../../types/url';
import './link.scss';
export const Link = (title: string, href: url) =>
`<a class="link" href="${href}">${title}</a>`;

View file

@ -1,5 +0,0 @@
@use '../../style/mixins' as *;
.link {
border-bottom: 2px solid var(--special-text-color);
}

View file

@ -1,6 +0,0 @@
import { html } from '../../types/html';
import './main.scss';
export const generate = (perspective: number): html => `
<main style="perspective: ${perspective}px"></main>
`;

View file

@ -1,50 +0,0 @@
@use '../../style/mixins' as *;
main {
height: 100%;
overflow-x: hidden;
overflow-y: scroll;
scroll-behavior: smooth;
// chrome scrolling does not work on PC without this
background: rgba(0, 0, 0, 0.005);
@media (hover: hover) {
&::-webkit-scrollbar-track,
&::-webkit-scrollbar {
background-color: transparent;
width: 12px;
}
&::-webkit-scrollbar-thumb {
background-color: var(--accent-color);
border-radius: var(--border-radius);
}
}
> .blob {
top: 0;
position: absolute;
width: 140px;
border-radius: 1000px;
transition: background-color var(--transition-time);
&:nth-child(odd) {
background-color: #fff9e0;
}
&:nth-child(even) {
background-color: #ffd6d6;
}
@media print {
display: none;
}
}
}
@include in-dark-mode {
main > .blob {
background-color: #2c477a;
}
}

View file

@ -1,113 +0,0 @@
import { getHeight } from '../../helper/get-height';
import { mix } from '../../helper/mix';
import { Random } from '../../helper/random';
import { sum } from '../../helper/sum';
import { PageElement } from '../page-element';
import { generate } from './main.html';
export class Main extends PageElement {
private static readonly perspective = 5;
private static readonly zMin = 6;
private static readonly zMax = 40;
private static readonly minHeight = 360;
private static readonly maxHeight = 740;
private static readonly minBlobCount = 20;
private static readonly blobCountScaler = 0.035;
private static readonly stableSeed = 42551;
private readonly topOffsetElementCount = 1;
private readonly bottomOffsetElementCount = 1;
private random = new Random();
private stableRandom = new Random();
private blobs: Array<HTMLElement> = [];
private contentHeight = 0;
constructor(...children: Array<PageElement | string>) {
const actualChildren = children.map((c) =>
c instanceof PageElement ? c : new PageElement(c)
);
super(generate(Main.perspective), actualChildren);
actualChildren.forEach((c) => this.attachElement(c));
}
protected initialize() {
super.initialize();
this.maintainYPosition();
}
private maintainBlobCount() {
const targetCount = Math.max(
Main.minBlobCount,
Math.ceil(window.innerWidth * Main.blobCountScaler)
);
const deltaCount = targetCount - this.blobs.length;
for (let i = 0; i < deltaCount; i++) {
const blob = this.createBlob();
this.blobs.push(blob);
this.htmlRoot.appendChild(blob);
}
for (let i = 0; i < -deltaCount; i++) {
const blob = this.blobs.pop();
this.htmlRoot.removeChild(blob!);
}
}
private createBlob(): HTMLElement {
const blob = document.createElement('div');
blob.className = 'blob';
const z = this.random.inInterval(Main.zMin, Main.zMax);
const halfScreenWidthAtFarPlane = Main.zMax / Main.perspective / 2 + 0.5;
const x = this.random.inInterval(
mix(0, 0.5 - halfScreenWidthAtFarPlane, z / Main.zMax),
mix(1, 0.5 + halfScreenWidthAtFarPlane, z / Main.zMax)
);
blob.style.left = `${x * 100}%`;
blob.style.transform = `translate3D(-50%, 0, ${-z}px) rotate(-20deg)`;
blob.style.zIndex = (-z).toFixed(0);
blob.style.opacity = (1 - (z - Main.zMin) / (Main.zMax - Main.zMin)).toString();
blob.style.height = `${this.random.inInterval(Main.minHeight, Main.maxHeight)}px`;
return blob;
}
private maintainYPosition() {
const siblings = Array.prototype.slice
.call(this.htmlRoot.childNodes)
.filter((n: HTMLElement) => !n.classList.contains('blob'));
const viewHeight = getHeight(this.htmlRoot);
const currentContentHeight = this.htmlRoot.scrollHeight / viewHeight;
if (currentContentHeight !== this.contentHeight) {
this.contentHeight = currentContentHeight;
this.maintainBlobCount();
const topOffset =
sum(siblings.slice(0, this.topOffsetElementCount).map(getHeight)) / viewHeight;
const bottomOffset =
sum(siblings.slice(-this.bottomOffsetElementCount).map(getHeight)) / viewHeight;
this.stableRandom.seed = Main.stableSeed;
this.blobs.forEach((b) => {
const y = this.stableRandom.inInterval(
topOffset,
this.contentHeight - bottomOffset - parseFloat(b.style.height) / viewHeight
);
b.style.top = `${y * 100}%`;
});
}
requestAnimationFrame(this.maintainYPosition.bind(this));
}
}

View file

@ -1,43 +0,0 @@
import { html } from '../types/html';
export class PageElement {
public readonly htmlRoot: HTMLElement;
public constructor(
content: html,
protected children: Array<PageElement> = []
) {
this.htmlRoot = PageElement.createElement(content);
}
public attachToDOM(target: HTMLElement) {
target.appendChild(this.htmlRoot);
this.initialize();
}
protected initialize(): void {
this.children.forEach((c) => c.initialize());
}
protected query(query: string): HTMLElement {
return this.htmlRoot.querySelector(query) as HTMLElement;
}
protected attachElement(element: PageElement) {
this.htmlRoot.appendChild(element.htmlRoot);
this.children.push(element);
}
protected attachElementByReplacing(query: string, element: PageElement) {
const old = this.query(query);
old.parentElement!.replaceChild(element.htmlRoot, old);
this.children.push(element);
}
private static createElement(from: html): HTMLElement {
// won't work for all elements, eg.: <td>
const element: HTMLElement = document.createElement('div');
element.innerHTML = from;
return element.firstElementChild as HTMLElement;
}
}

View file

@ -1,11 +0,0 @@
import { html } from '../../types/html';
import { Figure } from '../figure/figure';
export interface TimelineElementParameters {
date: string;
figure: Figure;
title: string;
description: string;
more?: Array<html>;
links: Array<html>;
}

View file

@ -1,42 +0,0 @@
import info from '../../../static/icons/info.svg';
import { titleToFragment } from '../../helper/title-to-fragment';
import { html } from '../../types/html';
import { ImageButtonFactory } from '../image-button/image-button.html';
import { TimelineElementParameters } from './timeline-element-parameters';
import './timeline-element.scss';
export const generate = (
{ date, title, description, more, links }: TimelineElementParameters,
showMore: string
): html => `
<article id="${titleToFragment(title).replace('#', '')}" class="timeline-element">
<div class="line-container">
<div class="line"></div>
<p class="date">${date}</p>
</div>
<div class="card">
<div class="figure"></div>
<div class="lower">
<h2>
<a href="${titleToFragment(title)}">${title}</a>
</h2>
<p class="description">${description}</p>
${
more
? `
<div class="more">
${more.map((t) => `<p>${t}</p>`).join('')}
</div>`
: ''
}
<div class="buttons">
${more ? ImageButtonFactory(info, showMore)() : ''}
${links.join('')}
</div>
</div>
</div>
</article>
`;

View file

@ -1,166 +0,0 @@
@use '../../style/mixins' as *;
@mixin q-dependent-line-container($q) {
> .line {
height: calc(#{$q} - var(--icon-size) / 2);
&:before {
height: calc(100% - #{$q} - var(--icon-size) / 2);
}
&:after {
top: calc(#{$q} - var(--icon-size) / 2);
}
}
> .date {
top: calc(#{$q} - 0.5ch);
}
}
.timeline-element {
display: flex;
width: var(--body-width);
margin: auto;
> .line-container {
position: relative;
@include q-dependent-line-container(33%);
transform: translate3d(0, 0, 0); // fix visual glitches in webkit
> .line {
&,
&:before {
background: var(--accent-color);
width: var(--line-width);
}
&:before,
&:after {
content: '';
position: absolute;
}
&:before {
left: 0;
bottom: 0;
}
&:after {
@include square(var(--icon-size));
border-radius: 1000px;
border: var(--line-width) solid var(--accent-color);
left: calc((var(--line-width) - var(--icon-size)) / 2);
}
}
> .date {
@include special-text-font();
position: absolute;
transform-origin: left center;
transform: rotate(30deg) translateX(calc(var(--icon-size) / 2 + 6px))
translateY(-10%);
padding-right: var(--normal-margin);
}
}
> .card {
@include blurred-background();
box-shadow: var(--shadow);
overflow: hidden;
border-radius: var(--border-radius);
background-color: var(--blurred-card-color);
transition: background-color var(--transition-time);
> .figure-container {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
> .lower {
> * {
padding: 0 var(--normal-margin);
margin-top: var(--small-margin);
}
> h2 {
text-align: center;
margin-bottom: -6px;
> a {
@include sub-title-font();
@include title-fragment-link();
}
}
> .description {
text-align: center;
padding: 0 var(--large-margin);
}
> .more {
overflow: hidden;
margin: 0;
height: 0;
transition: height var(--transition-time-long);
> p {
margin-top: var(--line-height);
}
}
$border-width: 1px;
> .buttons {
display: flex;
justify-content: center;
border-top: $border-width solid var(--normal-text-color);
margin: 0;
padding: 0;
margin-top: var(--small-margin);
> * {
flex: 1;
&:not(:last-child) {
border-right: $border-width solid var(--normal-text-color);
}
}
}
}
}
@include on-large-screen {
&:first-of-type > .line-container > .line {
border-radius: 100px 100px 0 0;
}
&:last-of-type > .line-container > .line:before {
border-radius: 0 0 100px 100px;
}
> .line-container {
min-width: 10rem;
}
&:not(:first-of-type) > .card {
margin-top: var(--large-margin);
}
}
@include on-small-screen {
flex-direction: column;
align-items: center;
> .line-container {
@include q-dependent-line-container(50%);
height: 150px;
min-width: 64%;
> .date {
transform: translateX(calc(var(--icon-size) / 2 + 12px)) translateY(-10%);
}
}
}
}

View file

@ -1,88 +0,0 @@
import { titleToFragment } from '../../helper/title-to-fragment';
import { BorderedImage } from '../figure/bordered-image/bordered-image';
import { ImageViewer } from '../image-viewer/image-viewer';
import { PageElement } from '../page-element';
import { TimelineElementParameters } from './timeline-element-parameters';
import { generate } from './timeline-element.html';
export class TimelineElement extends PageElement {
private isOpen = false;
private readonly more?: HTMLElement;
public constructor(
private timelineElement: TimelineElementParameters,
private readonly showMore: string,
private readonly showLess: string,
imageViewer?: ImageViewer
) {
super(generate(timelineElement, showMore));
addEventListener('resize', this.handleResize.bind(this));
this.more = this.query('.more');
if (this.more) {
this.query('.buttons > .image-button').addEventListener(
'click',
this.toggleOpen.bind(this)
);
}
if (timelineElement.figure instanceof BorderedImage) {
timelineElement.figure.imageViewer = imageViewer;
}
this.attachElementByReplacing('.figure', timelineElement.figure);
}
protected initialize(): void {
super.initialize();
if (titleToFragment(this.timelineElement.title) === window.location.hash) {
setTimeout(this.openMore.bind(this), 100);
}
}
private toggleOpen() {
if (this.isOpen) {
this.closeMore();
} else {
this.openMore();
}
}
private openMore() {
if (!this.more) {
return;
}
this.isOpen = true;
this.query('.buttons > .image-button p').innerText = this.showLess;
const deltaHeight = this.more.scrollHeight;
this.more.style.height = `${deltaHeight.toString()}px`;
}
private closeMore() {
if (!this.more) {
return;
}
this.isOpen = false;
this.query('.buttons > .image-button p').innerText = this.showMore;
this.more.style.height = '0';
}
private handleResize() {
if (!this.more) {
return;
}
if (this.isOpen) {
this.more.style.height = 'auto';
setTimeout(this.openMore.bind(this), 100);
}
}
}

View file

@ -1,9 +0,0 @@
import arrow from '../../../static/icons/arrow.svg';
import { html } from '../../types/html';
import './up-arrow-button.scss';
export const generate = (label: string): html => `
<button id="up-arrow-button" class="down" aria-label="${label}">
${arrow}
</button>
`;

View file

@ -1,33 +0,0 @@
@use '../../style/mixins' as *;
#up-arrow-button {
@include blurred-background();
cursor: pointer;
box-shadow: var(--shadow);
transition:
opacity var(--transition-time),
transform var(--transition-time-long);
border-radius: var(--border-radius);
position: fixed;
bottom: var(--small-margin);
right: var(--normal-margin);
padding: 0.25rem;
&:hover {
transform: scale(1.1);
}
&.down {
transform: rotate(180deg);
&:hover {
transform: scale(1.1) rotate(180deg);
}
}
svg {
@include square(var(--large-icon-size));
}
}

View file

@ -1,57 +0,0 @@
import { PageElement } from '../page-element';
import { generate } from './up-arrow-button.html';
export class UpArrowButton extends PageElement {
private static readonly defaultTimeToLive = 3500;
private static readonly interval = 50;
private timeToLive = 0;
public constructor(
private scrollTarget: PageElement,
private turningThreshold: PageElement,
label: string
) {
super(generate(label));
this.htmlRoot.addEventListener('click', this.scrollToTop.bind(this));
setInterval(() => {
this.timeToLive = Math.max(0, this.timeToLive - UpArrowButton.interval);
if (this.timeToLive == 0) {
this.htmlRoot.style.opacity = '0';
}
}, UpArrowButton.interval);
}
protected initialize() {
this.scrollTarget.htmlRoot.addEventListener('scroll', () => {
this.timeToLive = UpArrowButton.defaultTimeToLive;
this.htmlRoot.style.opacity = '1';
});
this.htmlRoot.addEventListener('mouseover', () => {
this.timeToLive = UpArrowButton.defaultTimeToLive;
this.htmlRoot.style.opacity = '1';
});
new IntersectionObserver((e) => {
if (e[0].isIntersecting) {
this.htmlRoot.classList.remove('down');
} else {
this.htmlRoot.classList.add('down');
}
}).observe(this.turningThreshold.htmlRoot);
super.initialize();
}
private scrollToTop() {
this.scrollTarget.htmlRoot.scrollTo({
top: this.htmlRoot.classList.contains('down')
? this.scrollTarget.htmlRoot.scrollHeight
: 0,
left: 0,
behavior: 'smooth',
});
}
}

33
src/pages/404.astro Normal file
View file

@ -0,0 +1,33 @@
---
import ArticleList from '../components/ArticleList.astro';
import Page from '../layouts/Page.astro';
import { getPublishedPosts } from '../lib/site';
const RECENT_ARTICLES = 5;
const posts = await getPublishedPosts();
const recent = posts.slice(0, RECENT_ARTICLES);
---
<Page
title="This page doesn't exist"
description="The link you followed may be broken, or the page may have moved."
noindex
>
<div class="empty-state">
<p>
Try the <a href="/articles/">articles archive</a>, the
<a href="/projects/">project index</a>, the
<a href="/tags/">tag index</a>, or head back to the
<a href="/">homepage</a>.
</p>
</div>
<section class="home-section">
<div class="section-heading">
<h2 id="recent-articles-404">Recent articles</h2>
<a href="/articles/">All articles <span aria-hidden="true">→</span></a>
</div>
<ArticleList posts={recent} />
</section>
</Page>

120
src/pages/about.astro Normal file
View file

@ -0,0 +1,120 @@
---
import ArticleList from '../components/ArticleList.astro';
import Page from '../layouts/Page.astro';
import {
absoluteUrl,
buildPersonJsonLd,
getPublishedPosts,
optimizeOgImage,
site,
} from '../lib/site';
import defaultOg from '../assets/og-default.jpg';
const STARTING_POINTS = 4;
const posts = await getPublishedPosts();
const startingPoints = posts
.filter((post) => post.data.audience === 'recruiter-relevant')
.sort((a, b) => (a.data.featuredOrder ?? 99) - (b.data.featuredOrder ?? 99))
.slice(0, STARTING_POINTS);
const personImage = await optimizeOgImage(defaultOg);
// Canonical Person JSON-LD. Other pages reference this entity by @id.
const personJsonLd = buildPersonJsonLd({
jobTitle: 'Software Engineer',
description:
'Software engineer with an MSc in Computer Science working on AI/ML systems, web platforms, graphics, simulations, and tools.',
knowsAbout: [
'Software architecture',
'AI/ML systems',
'Web platforms',
'Computer graphics',
'Simulations',
'Data visualization',
],
image: absoluteUrl(personImage.src),
mainEntityOfPage: absoluteUrl('/about/'),
});
---
<Page
title="About"
description="A direct summary of my background, technical interests, and best starting points."
jsonLd={personJsonLd}
ogType="profile"
>
<div class="prose">
<p>
I am Andras Schmelczer, a software engineer with an MSc in Computer Science and more
than six years of professional engineering experience. My work spans AI/ML systems,
web platforms, graphics, simulations, and tools, and I like projects where
architecture, constraints, and product usefulness all matter.
</p>
<p>
I am especially interested in architecting and building large-scale systems,
particularly around AI/ML. In my own time I also return to shaders, data
visualization, simulations, and occasionally microcontrollers. The
<a href="/articles/">articles</a> and <a href="/projects/">projects</a> indexes are the
best way to understand that range; the CV and contact links are here when a direct summary
is more useful.
</p>
</div>
<section class="about-section facts">
<h2 id="quick-facts">Quick Facts</h2>
<dl>
<div>
<dt>Focus</dt>
<dd>
Software systems, AI deployment, architecture, graphics, data visualization
</dd>
</div>
<div>
<dt>Education</dt>
<dd>MSc in Computer Science</dd>
</div>
<div>
<dt>Contact</dt>
<dd>
<address>
<a href={`mailto:${site.email}`}>{site.email}</a>
</address>
</dd>
</div>
<div>
<dt>Links</dt>
<dd class="about-links">
<a href={site.cv} rel="noopener">CV</a>
<a href={site.github} rel="noopener me">GitHub</a>
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
</dd>
</div>
</dl>
</section>
<section class="about-section">
<div class="section-heading">
<h2 id="best-starting-points">Best Starting Points</h2>
<a href="/articles/">Browse all articles <span aria-hidden="true">→</span></a>
</div>
<ArticleList posts={startingPoints} />
</section>
<section class="about-section facts">
<h2 id="working-style">How I Work</h2>
<div class="prose">
<p>
I am strongest when I can reason through a system end to end: the data model, the
API shape, the performance constraints, the operational risks, and the human path
through the tool. The projects on this site are older and newer examples of that
habit.
</p>
<p>
I care about simple interfaces over accidental complexity, and I prefer technical
depth that can be explained clearly. That is why this site is structured around
articles rather than screenshots and slogans.
</p>
</div>
</section>
</Page>

View file

@ -0,0 +1,16 @@
---
import Post from '../../layouts/Post.astro';
import { entrySlug, getPublishedPosts } from '../../lib/site';
export async function getStaticPaths() {
const posts = await getPublishedPosts();
return posts.map((post) => ({
params: { slug: entrySlug(post) },
props: { post },
}));
}
const { post } = Astro.props;
---
<Post post={post} />

View file

@ -0,0 +1,74 @@
---
import ArticleList from '../../components/ArticleList.astro';
import TagList from '../../components/TagList.astro';
import Page from '../../layouts/Page.astro';
import {
absoluteUrl,
articlePath,
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
getAllTags,
getPublishedPosts,
optimizeOgImage,
site,
yearOf,
} from '../../lib/site';
const description =
'Technical articles and notes about projects, systems, AI deployment, graphics, simulations, and tools.';
const posts = await getPublishedPosts();
const years = [...new Set(posts.map((post) => yearOf(post.data.date)))];
const tags = getAllTags(posts);
const postOgImages = await Promise.all(
posts.map((post) => optimizeOgImage(post.data.thumbnail.src))
);
const personId = absoluteUrl('/about/#person');
const blogJsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
name: `${site.name} — Articles`,
url: absoluteUrl('/articles/'),
description,
publisher: { '@id': personId },
blogPost: posts.map((post, index) => ({
'@type': 'BlogPosting',
headline: post.data.title,
description: post.data.description,
datePublished: post.data.date.toISOString(),
url: absoluteUrl(articlePath(post)),
author: { '@id': personId },
image: absoluteUrl(postOgImages[index].src),
keywords: post.data.tags.join(', '),
})),
};
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ articles: true }));
const jsonLd = [blogJsonLd, breadcrumbJsonLd];
---
<Page title="Articles" description={description} jsonLd={jsonLd}>
<nav id="tag-filter" class="tag-filter" aria-label="Browse by tag">
<span>Browse by tag</span>
<TagList tags={tags} />
</nav>
{
years.map((year) => {
const postsForYear = posts.filter((post) => yearOf(post.data.date) === year);
return (
<section class="archive-year">
<h2 id={`year-${year}`}>{year}</h2>
<ArticleList
posts={postsForYear}
showYear={false}
eagerFirstThumbnail={year === years[0]}
/>
</section>
);
})
}
</Page>

69
src/pages/index.astro Normal file
View file

@ -0,0 +1,69 @@
---
import ArticleList from '../components/ArticleList.astro';
import ProjectList from '../components/ProjectList.astro';
import TagList from '../components/TagList.astro';
import Base from '../layouts/Base.astro';
import {
buildPersonJsonLd,
getAllTags,
getProjects,
getPublishedPosts,
} from '../lib/site';
const LATEST_ARTICLES = 5;
const posts = await getPublishedPosts();
const latestPosts = posts.slice(0, LATEST_ARTICLES);
const projects = await getProjects();
const selectedProjects = projects.filter((project) => project.data.selected);
const tags = getAllTags(posts);
// Reference the canonical Person (defined on /about/) by @id.
const personJsonLd = buildPersonJsonLd();
---
<Base jsonLd={personJsonLd}>
<section class="home-intro">
<p class="eyebrow">
Software systems, AI deployment, graphics, simulations, and tools
</p>
<h1>
Andras Schmelczer writes about building software that has to work under real
constraints.
</h1>
<p>
I am a software engineer with an MSc in Computer Science. This site is mostly a
notebook of technical articles and project writeups; the hiring details live on the
<a href="/about/">About</a> page.
</p>
</section>
<section class="home-section">
<div class="section-heading">
<h2 id="latest-articles">Latest Articles</h2>
<a href="/articles/"
>All {posts.length}
{posts.length === 1 ? 'article' : 'articles'}
<span aria-hidden="true">→</span></a
>
</div>
<ArticleList posts={latestPosts} />
</section>
<section class="home-section">
<div class="section-heading">
<h2 id="home-selected-projects">Selected Projects</h2>
<a href="/projects/">All projects <span aria-hidden="true">→</span></a>
</div>
<ProjectList projects={selectedProjects} />
</section>
<section class="home-section">
<div class="section-heading">
<h2 id="browse-by-topic">Browse by Topic</h2>
</div>
<div class="tag-cloud">
<TagList tags={tags} />
</div>
</section>
</Base>

View file

@ -0,0 +1,42 @@
---
import ProjectList from '../../components/ProjectList.astro';
import Page from '../../layouts/Page.astro';
import {
absoluteUrl,
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
getProjects,
site,
} from '../../lib/site';
const description =
'A compact index of systems, tools, simulations, graphics experiments, games, and older work.';
const projects = await getProjects();
const selected = projects.filter((project) => project.data.selected);
const older = projects.filter((project) => !project.data.selected);
const collectionJsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${site.name} — Projects`,
url: absoluteUrl('/projects/'),
description,
};
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ projects: true }));
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
---
<Page title="Projects" description={description} jsonLd={jsonLd}>
<section class="project-section">
<h2 id="selected-projects">Selected Projects</h2>
<ProjectList projects={selected} eagerFirstThumbnail />
</section>
<section class="project-section">
<h2 id="older-projects">Older and Smaller Projects</h2>
<ProjectList projects={older} />
</section>
</Page>

103
src/pages/rss.xml.ts Normal file
View file

@ -0,0 +1,103 @@
import rss from '@astrojs/rss';
import type { APIRoute } from 'astro';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { render } from 'astro:content';
import ogDefault from '../assets/og-default.jpg';
import {
absoluteUrl,
articlePath,
getPublishedPosts,
optimizeOgImage,
site,
} from '../lib/site';
// Escape characters that would otherwise break XML parsing inside text nodes
// (the `customData` strings are inserted as-is by @astrojs/rss).
function escapeXml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// Rewrite root-relative URLs to absolute so RSS readers (which load the HTML
// outside any page context) can still resolve assets and links.
function absolutizeUrls(html: string, baseUrl: string) {
return html
.replace(/(<(?:a|link)\b[^>]*\bhref=")(\/[^"]*)(")/g, `$1${baseUrl}$2$3`)
.replace(
/(<(?:img|source|video|audio)\b[^>]*\bsrc=")(\/[^"]*)(")/g,
`$1${baseUrl}$2$3`
)
.replace(/(\bsrcset=")([^"]+)(")/g, (_, prefix, value, suffix) => {
const rewritten = value
.split(',')
.map((candidate: string) => {
const trimmed = candidate.trim();
if (!trimmed.startsWith('/')) return trimmed;
return baseUrl + trimmed;
})
.join(', ');
return prefix + rewritten + suffix;
});
}
export const GET: APIRoute = async () => {
const posts = await getPublishedPosts();
const feedUrl = absoluteUrl('/rss.xml');
const channelImage = await optimizeOgImage(ogDefault);
const channelImageUrl = absoluteUrl(channelImage.src);
const creator = escapeXml(site.name);
const container = await AstroContainer.create();
const items = await Promise.all(
posts.map(async (post) => {
const url = absoluteUrl(articlePath(post));
const updated = post.data.updated
? `<atom:updated>${post.data.updated.toISOString()}</atom:updated>`
: '';
const { Content } = await render(post);
const html = await container.renderToString(Content);
// @astrojs/rss XML-escapes the `content` string and emits it inside
// <content:encoded>. RSS readers decode the escaped HTML the same as if
// it were wrapped in CDATA, so escaping is fine and safer to author.
const content = absolutizeUrls(html, site.url);
return {
title: post.data.title,
description: post.data.description,
pubDate: post.data.date,
link: url,
author: `${site.email} (${site.name})`,
categories: [...post.data.tags],
content,
customData: [`<dc:creator>${creator}</dc:creator>`, updated]
.filter(Boolean)
.join('\n'),
};
})
);
return rss({
title: site.name,
description: site.description,
site: site.url,
xmlns: {
atom: 'http://www.w3.org/2005/Atom',
content: 'http://purl.org/rss/1.0/modules/content/',
dc: 'http://purl.org/dc/elements/1.1/',
},
customData: [
'<language>en-us</language>',
`<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>`,
`<atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />`,
'<image>',
` <url>${channelImageUrl}</url>`,
` <title>${escapeXml(site.name)}</title>`,
` <link>${site.url}</link>`,
'</image>',
].join('\n'),
items,
});
};

View file

@ -0,0 +1,49 @@
---
import ArticleList from '../../components/ArticleList.astro';
import Breadcrumbs from '../../components/Breadcrumbs.astro';
import TagList from '../../components/TagList.astro';
import Page from '../../layouts/Page.astro';
import {
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
getAllTags,
getPublishedPosts,
tagSlug,
} from '../../lib/site';
export async function getStaticPaths() {
const posts = await getPublishedPosts();
return getAllTags(posts).map((tag) => ({
params: { tag: tagSlug(tag) },
props: { tag },
}));
}
const { tag } = Astro.props;
const posts = await getPublishedPosts();
const allTags = getAllTags(posts);
const filteredPosts = posts.filter((post) => post.data.tags.some((t) => t === tag));
const title = `Articles tagged "${tag}"`;
const trail = buildBreadcrumbTrail({ tag });
const visibleTrail = trail.map((c, i) => ({
label: c.name,
href: i === trail.length - 1 ? undefined : c.href,
}));
const breadcrumbJsonLd = buildBreadcrumbJsonLd(trail);
---
<Page
title={title}
description={`Project articles and technical notes filed under #${tag}.`}
jsonLd={breadcrumbJsonLd}
noindex
>
<Breadcrumbs slot="breadcrumbs" items={visibleTrail} />
<nav class="tag-filter" aria-label="Browse other tags">
<span>Browse other tags</span>
<TagList tags={allTags} currentTag={tag} />
</nav>
<h2 class="sr-only">Articles</h2>
<ArticleList posts={filteredPosts} eagerFirstThumbnail />
</Page>

View file

@ -0,0 +1,45 @@
---
import TagList from '../../components/TagList.astro';
import Page from '../../layouts/Page.astro';
import {
absoluteUrl,
buildBreadcrumbJsonLd,
buildBreadcrumbTrail,
getAllTags,
getPublishedPosts,
site,
} from '../../lib/site';
const description = 'Every tag used across the articles archive.';
const posts = await getPublishedPosts();
const tags = getAllTags(posts);
const tagCounts: Record<string, number> = {};
for (const post of posts) {
for (const tag of post.data.tags) {
tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
}
}
const collectionJsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${site.name} — Tags`,
url: absoluteUrl('/tags/'),
description,
};
const breadcrumbJsonLd = buildBreadcrumbJsonLd(buildBreadcrumbTrail({ tagsIndex: true }));
const jsonLd = [collectionJsonLd, breadcrumbJsonLd];
---
<Page title="Tags" description={description} jsonLd={jsonLd}>
<p class="dek">
{posts.length}
{posts.length === 1 ? 'article' : 'articles'} across {tags.length}
{tags.length === 1 ? 'tag' : 'tags'}.
</p>
<TagList tags={tags} counts={tagCounts} />
</Page>

26
src/scripts/theme-init.js Normal file
View file

@ -0,0 +1,26 @@
// FOUC prevention: runs in <head> before paint. Sets the theme on <html> so
// the page renders with the right colors on first load. The theme switcher
// button is wired up separately, after it is parsed, in Header.astro.
//
// Keep THEME_BG values in sync with --color-bg in global.css. They drive the
// browser-chrome <meta name="theme-color"> so it follows the user's manual
// toggle (the static media-keyed metas only tracked OS preference).
(function () {
document.documentElement.classList.remove('no-js');
document.documentElement.classList.add('js');
var STORAGE_KEY = 'theme';
var THEME_BG = { light: '#fbfaf7', dark: '#151514' };
var saved = null;
try {
var value = localStorage.getItem(STORAGE_KEY);
if (value === 'light' || value === 'dark') saved = value;
} catch (e) {}
var theme = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
for (var i = 0; i < themeColorMetas.length; i += 1) {
themeColorMetas[i].setAttribute('content', THEME_BG[theme]);
}
})();

View file

@ -1,25 +0,0 @@
/* comfortaa-regular - latin */
@font-face {
font-family: 'Comfortaa';
font-style: normal;
font-weight: 400;
font-display: swap;
src:
local(''),
url('../../static/fonts/comfortaa-v40-latin-regular.woff2') format('woff2'),
/* Chrome 26+, Opera 23+, Firefox 39+ */
url('../../static/fonts/comfortaa-v40-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
/* open-sans-regular - latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src:
local(''),
url('../../static/fonts/open-sans-v34-latin-regular.woff2') format('woff2'),
/* Chrome 26+, Opera 23+, Firefox 39+ */
url('../../static/fonts/open-sans-v34-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}

View file

@ -1,168 +0,0 @@
@use 'sass:math';
$breakpoint-width: 700px !default;
@mixin on-small-screen() {
@media (max-width: ($breakpoint-width - 1px)) {
@content;
}
}
@mixin on-large-screen() {
@media (min-width: $breakpoint-width) {
@content;
}
}
@mixin in-dark-mode() {
html[theme='dark'] {
@content;
}
}
@mixin image-button($icon-size) {
display: block;
box-sizing: content-box;
cursor: pointer;
&:hover svg {
transform: translateX(-50%) translateY(-50%) scale(1.15);
}
svg {
@include absolute-center;
@include square($icon-size);
transition: transform var(--transition-time);
transform-origin: center center;
}
}
@mixin title-fragment-link() {
position: relative;
&:before {
content: '#';
position: absolute;
left: -0.5ch;
top: 50%;
opacity: 0;
transform: translateX(-100%) translateY(-50%);
transition: opacity var(--transition-time);
}
&:hover:before {
opacity: 0.5;
}
}
@mixin center-children() {
display: flex;
align-items: center;
justify-content: center;
}
@mixin absolute-center() {
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
}
@mixin blurred-background() {
backdrop-filter: blur(var(--blur-radius));
-webkit-backdrop-filter: blur(var(--blur-radius));
@supports not (
(backdrop-filter: blur(var(--blur-radius))) or
(-webkit-backdrop-filter: blur(var(--blur-radius)))
) {
background-color: var(--card-color);
}
}
@mixin square($size) {
width: $size;
height: $size;
}
@mixin title-font() {
font:
400 3rem 'Comfortaa',
sans-serif;
color: var(--normal-text-color);
line-height: 1;
@include on-small-screen {
font-size: 3rem;
line-height: 1.1;
}
}
@mixin sub-title-font() {
font:
400 1.75rem 'Comfortaa',
sans-serif;
color: var(--normal-text-color);
hyphens: auto;
}
@mixin main-font() {
font:
400 1.1rem 'Open Sans',
sans-serif;
color: var(--normal-text-color);
line-height: 1.8;
hyphens: auto;
}
@mixin special-text-font() {
font:
400 1rem 'Open Sans',
sans-serif;
color: var(--special-text-color);
hyphens: auto;
font-style: italic;
}
@mixin link {
$border-shift: 10px;
$line-width: 2px;
@include special-text-font();
cursor: pointer;
position: relative;
display: inline-block;
overflow: hidden;
padding: 0 3px $line-width 0;
&:before,
&:after {
content: '';
display: block;
position: absolute;
bottom: 0;
}
&:before {
width: calc(100% + #{$border-shift});
border-bottom: $line-width dashed var(--accent-color);
transition: transform var(--transition-time);
}
&:after {
width: 100%;
height: $line-width;
background: linear-gradient(
90deg,
var(--card-color) 0,
transparent 4px,
transparent calc(100% - 4px),
var(--card-color) 100%
);
}
&:hover:before {
transform: translateX(-$border-shift);
}
}

View file

@ -1,44 +0,0 @@
@use 'mixins' as *;
:root {
--transition-time: 200ms;
--transition-time-long: 350ms;
--line-width: 4px;
--line-height: 1.125rem;
--accent-color: #b7455e;
--sun-color: #f7f78c;
--very-light-text-color: #ffffff;
--background: #ffffff;
--normal-text-color: #31343f;
--card-color: #ffffff;
--blurred-card-color: transparent;
--blur-radius: 12px;
--special-text-color: var(--accent-color);
--inset-shadow: inset 0 0 4px 1px rgba(0, 0, 0, 0.05), inset 0 0 5px rgba(0, 0, 0, 0.2);
--border-radius: 0.85rem;
--large-margin: 4.6rem;
--normal-margin: 2.8rem;
--small-margin: 1.4rem;
--shadow: 0 0 5px 2px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.2);
--icon-size: 2.8rem;
--large-icon-size: 3.75rem;
--body-width: min(80%, 60rem);
}
@include on-small-screen {
:root {
--body-width: 90%;
--large-margin: 2.8rem;
--normal-margin: 2rem;
}
}
@include in-dark-mode {
--background: #242638;
--normal-text-color: #ffffff;
--card-color: #263551;
--blurred-card-color: #212f4a77;
--special-text-color: #ffffff;
--inset-shadow: inset 0 0 10px 2px rgba(0, 0, 0, 0.3), inset 0 0 4px rgba(0, 0, 0, 0.5);
}

1656
src/styles/global.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -1 +0,0 @@
export type html = string;

View file

@ -1,14 +0,0 @@
import { url } from './url';
export type ResponsiveImage = {
srcSet: string;
src: url;
placeholder: string;
width: number;
height: number;
images: Array<{
path: url;
width: number;
height: number;
}>;
};

View file

@ -1 +0,0 @@
export type url = string;