Initial site: Astro + M3 Expressive, projects, blog

Personal site & blog for jeanlucmakiola.de.

- Astro 7 static output; self-hosted Inter + JetBrains Mono (Fontsource)
- Material 3 Expressive design tokens (red accent over neutral surfaces)
- Home (hero + about + selected work + latest writing)
- /work index + per-app pages (Calendula, Agendula, floret-kit) with
  build-time Gitea release tags, brand-icon link rows, features grid,
  and an at-a-glance facts panel
- Blog via Markdown content collection; RSS, sitemap, robots, OG/SEO
- Privacy-respecting Umami analytics, env-gated (off in dev)
- Dockerfile (build -> Caddy) + Caddyfile for Coolify deploy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Luc Makiola
2026-06-28 13:39:08 +02:00
commit 074199c656
35 changed files with 6954 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
---
// Umami analytics. Renders nothing unless both env vars are set, so analytics
// stays off in local dev. Configure in production (Coolify env):
// PUBLIC_UMAMI_SRC e.g. https://umami.yourdomain/script.js
// PUBLIC_UMAMI_WEBSITE the website id (UUID) from your Umami dashboard
const src = import.meta.env.PUBLIC_UMAMI_SRC;
const websiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE;
const enabled = Boolean(src && websiteId);
---
{enabled && <script is:inline defer src={src} data-website-id={websiteId}></script>}

View File

@@ -0,0 +1,59 @@
---
// SEO + document head. Renders title, meta description, canonical URL,
// Open Graph / Twitter tags, RSS autodiscovery, and the favicon.
import { SITE } from '../consts';
interface Props {
title: string;
description?: string;
/** Optional absolute or root-relative OG image path. */
image?: string;
/** 'website' for pages, 'article' for blog posts. */
type?: 'website' | 'article';
}
const {
title,
description = SITE.description,
image = '/og-default.png',
type = 'website',
} = Astro.props;
// Astro.site comes from astro.config `site`. Canonical = site + current path.
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const imageURL = new URL(image, Astro.site);
const fullTitle = title === SITE.title ? title : `${title} · ${SITE.title}`;
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<meta name="author" content={SITE.author} />
<link rel="canonical" href={canonicalURL} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link
rel="alternate"
type="application/rss+xml"
title={SITE.title}
href={new URL('rss.xml', Astro.site)}
/>
<link rel="sitemap" href="/sitemap-index.xml" />
<!-- Open Graph -->
<meta property="og:type" content={type} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:image" content={imageURL} />
<meta property="og:site_name" content={SITE.title} />
<meta property="og:locale" content={SITE.locale} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={imageURL} />

View File

@@ -0,0 +1,23 @@
---
import { Icon } from 'astro-icon/components';
import { SITE, SOCIALS } from '../consts';
const year = new Date().getFullYear();
---
<footer class="site-footer">
<div class="site-footer__inner">
<p>© {year} {SITE.author}</p>
{
SOCIALS.length > 0 && (
<nav aria-label="Social" class="socials">
{SOCIALS.map((s) => (
<a href={s.href} rel="me noopener">
<Icon name={s.icon} />
{s.label}
</a>
))}
</nav>
)
}
</div>
</footer>

View File

@@ -0,0 +1,16 @@
---
import { SITE } from '../consts';
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString(SITE.lang, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
</time>

View File

@@ -0,0 +1,30 @@
---
import { SITE } from '../consts';
const path = Astro.url.pathname;
const isActive = (href: string) =>
href === '/' ? path === '/' : path.startsWith(href);
const nav = [
{ label: 'Home', href: '/' },
{ label: 'Work', href: '/work' },
{ label: 'Blog', href: '/blog' },
];
---
<header class="site-header">
<div class="site-header__inner">
<a class="logotype" href="/" aria-label={`${SITE.title} — home`}>
JLM<span class="dot">.</span>
</a>
<nav aria-label="Primary">
{
nav.map((item) => (
<a href={item.href} aria-current={isActive(item.href) ? 'page' : undefined}>
{item.label}
</a>
))
}
</nav>
</div>
</header>

16
src/consts.ts Normal file
View File

@@ -0,0 +1,16 @@
// Central site config. Imported by astro.config.mjs, layouts, RSS, and SEO head.
export const SITE = {
url: 'https://jeanlucmakiola.de',
title: 'Jean-Luc Makiola',
description: 'Personal site of Jean-Luc Makiola — projects, writing, and notes.',
author: 'Jean-Luc Makiola',
lang: 'en',
locale: 'en_US',
} as const;
// Social / external links shown in the footer. `icon` is an Iconify name.
export const SOCIALS: { label: string; href: string; icon: string }[] = [
{ label: 'Gitea', href: 'https://gitea.jeanlucmakiola.de/makiolaj', icon: 'simple-icons:gitea' },
{ label: 'Ko-fi', href: 'https://ko-fi.com/jeanlucmakiola', icon: 'simple-icons:kofi' },
{ label: 'Email', href: 'mailto:mail@jeanlucmakiola.de', icon: 'mdi:email-outline' },
];

51
src/content.config.ts Normal file
View File

@@ -0,0 +1,51 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
// Blog posts: Markdown/MDX files in src/content/blog/.
// Frontmatter is validated against this schema at build time.
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
// Accepts an ISO date string in frontmatter, parsed to a Date.
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
// Set true to keep a post out of listings/feeds while you work on it.
draft: z.boolean().default(false),
}),
});
// Projects / apps: one Markdown file per app in src/content/projects/.
// Frontmatter holds the structured bits (status, links); the body is the
// long-form overview + features rendered on the per-app page.
const projects = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/projects' }),
schema: z.object({
name: z.string(),
// lower = earlier in listings
order: z.number().default(99),
status: z.string(),
released: z.boolean().default(false),
platform: z.string().optional(),
license: z.string().optional(),
summary: z.string(),
// tech stack chips shown in the "At a glance" panel
tech: z.array(z.string()).default([]),
// structured feature cards (rendered as a grid, not prose)
features: z
.array(z.object({ title: z.string(), body: z.string() }))
.default([]),
links: z
.object({
gitea: z.string().url().optional(),
fdroid: z.string().url().optional(),
donate: z.string().url().optional(),
translate: z.string().url().optional(),
})
.default({}),
}),
});
export const collections = { blog, projects };

View File

@@ -0,0 +1,14 @@
---
title: Hello, world
description: First post — what this site is and how it's built.
pubDate: 2026-06-28
tags: [meta]
draft: false
---
This is the first post on my new site. It's built with [Astro](https://astro.build),
deployed on a self-hosted [Coolify](https://coolify.io), and kept honest with
privacy-respecting analytics.
Posts live as Markdown files in the repository — so writing here is just editing
text and pushing a commit. More to come.

View File

@@ -0,0 +1,42 @@
---
name: Agendula
order: 2
status: In development
released: false
platform: Android 10+
license: MIT
summary: >-
The task-list sibling to Calendula — a pure front-end over the OpenTasks
provider (CalDAV VTODOs), with no reinvented sync stack.
tech:
- Kotlin
- Jetpack Compose
- Material 3 Expressive
features:
- title: Provider-native
body: >-
A pure front-end over the OpenTasks TaskContract provider. DAVx5,
SmoothSync, and DecSync sync your CalDAV VTODOs in; Agendula reads and
writes them — no own database.
- title: Data layer done
body: >-
Provider resolution, live-updating reads, writes, smart-list filtering,
and a self-scheduled reminder engine are built and unit-tested.
- title: UI in progress
body: >-
The Material 3 Expressive screens are being built on top of the tested
data layer, one at a time.
- title: Open standards only
body: >-
CalDAV, iCalendar, and DecSync are the lane. Proprietary task services are
out of scope by design — they would mean owning a sync stack.
links:
gitea: https://gitea.jeanlucmakiola.de/makiolaj/agendula
donate: https://ko-fi.com/jeanlucmakiola
---
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula
is a pure front-end over the **OpenTasks `TaskContract` provider**. The name
rhymes with its sibling on purpose: *Agendula* is *agenda* — Latin for "things
to be done" — given Calendula's `-ula` ending. A Calendula flower head is a
cluster of many small *florets*, so the two apps are florets of one bloom.

View File

@@ -0,0 +1,48 @@
---
name: Calendula
order: 1
status: Released
released: true
platform: Android 10+
license: MIT
summary: >-
A Material 3 Expressive calendar for Android. Reads, writes, and reminds on
top of the system calendar — any CalDAV account just appears — with zero
network access of its own.
tech:
- Kotlin
- Jetpack Compose
- Material 3 Expressive
features:
- title: Calendar views
body: >-
Month, week, and day views with a one-tap switcher. Full event detail —
attendees and responses, reminders, humanized recurrence, availability,
visibility, and foreign time zones.
- title: Safe editing
body: >-
Scoped writes for recurring events (only this, this-and-following, or the
whole series), a recurrence picker for presets and custom rules, and
conflict-safe saves that ask before overwriting.
- title: Built-in reminders
body: >-
Delivered by Calendula itself as notifications — essential when it's your
only calendar app, since Android delegates reminder delivery to calendar
apps. Tap a reminder to land on the event.
- title: Private by default
body: >-
Zero telemetry, zero analytics, no internet permission — your data never
leaves the device. Dynamic color on Android 12+, German and English UI.
links:
gitea: https://gitea.jeanlucmakiola.de/makiolaj/calendula
fdroid: https://f-droid.org/packages/de.jeanlucmakiola.calendula/
donate: https://ko-fi.com/jeanlucmakiola
translate: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
---
Calendula is named after the flower whose name — like the word *calendar*
comes from the Latin *kalendae*, the first day of the month. It lives entirely
on top of Android's `CalendarContract`: any calendar synced to your device
(CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply appears, and
everything you create or edit syncs back the same way. **No own database, no
sync stack reinvented.**

View File

@@ -0,0 +1,37 @@
---
name: floret-kit
order: 3
status: Design system
released: false
platform: Android library
license: MIT
summary: >-
The shared Material 3 Expressive design system and plumbing behind the Floret
app family — so each app draws from one bloom instead of reinventing it.
tech:
- Kotlin
- Gradle composite build
features:
- title: Built from source
body: >-
Embedded as a git submodule and wired in with a Gradle composite build —
no published artifacts, which keeps every app reproducible for F-Droid.
- title: core-time
body: >-
Pure-Kotlin date/time helpers: local-day windows for smart-list logic and
locale/zone-aware display formatting. No Android, no dependencies.
- title: Growing
body: >-
Identity/theme, components, screen recipes, and provider / prefs /
reminders / crash plumbing land as they're extracted from the apps.
links:
gitea: https://gitea.jeanlucmakiola.de/makiolaj/floret-kit
donate: https://ko-fi.com/jeanlucmakiola
---
"Floret" is the family's design language: a Calendula flower head is a cluster
of many small *florets*, and each app is one of them. floret-kit is the bloom
they share — so a new app doesn't reinvent a settings screen, an edit form, or a
theme; it draws from here. Gradle's dependency substitution maps
`de.jeanlucmakiola.floret:<module>` to local source, so editing the kit updates
every app immediately.

View File

@@ -0,0 +1,38 @@
---
import BaseHead from '../components/BaseHead.astro';
import Analytics from '../components/Analytics.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import { SITE } from '../consts';
// Self-hosted brand fonts (privacy-respecting — no Google CDN).
import '@fontsource-variable/inter';
import '@fontsource-variable/jetbrains-mono';
import '../styles/global.css';
interface Props {
title: string;
description?: string;
image?: string;
type?: 'website' | 'article';
/** 'narrow' caps width for long-form reading (blog, posts, tags). */
width?: 'wide' | 'narrow';
}
const { title, description, image, type, width = 'wide' } = Astro.props;
---
<!doctype html>
<html lang={SITE.lang}>
<head>
<BaseHead title={title} description={description} image={image} type={type} />
<Analytics />
</head>
<body>
<Header />
<main class:list={['container', { 'container--narrow': width === 'narrow' }]}>
<slot />
</main>
<Footer />
</body>
</html>

View File

@@ -0,0 +1,35 @@
---
import BaseLayout from './BaseLayout.astro';
import FormattedDate from '../components/FormattedDate.astro';
import type { CollectionEntry } from 'astro:content';
interface Props {
post: CollectionEntry<'blog'>['data'];
}
const { post } = Astro.props;
const { title, description, pubDate, updatedDate, tags } = post;
---
<BaseLayout title={title} description={description} type="article" width="narrow">
<article class="prose">
<header class="post-header">
<h1>{title}</h1>
<p class="post-meta">
<FormattedDate date={pubDate} />
{updatedDate && <span> · updated <FormattedDate date={updatedDate} /></span>}
</p>
{
tags.length > 0 && (
<ul class="chip-set">
{tags.map((tag) => (
<li>
<a class="chip" href={`/tags/${tag}`}>#{tag}</a>
</li>
))}
</ul>
)
}
</header>
<slot />
</article>
</BaseLayout>

View File

@@ -0,0 +1,20 @@
---
import { getCollection, render } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';
import type { GetStaticPaths } from 'astro';
export const getStaticPaths = (async () => {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}) satisfies GetStaticPaths;
const { post } = Astro.props;
const { Content } = await render(post);
---
<BlogPost post={post.data}>
<Content />
</BlogPost>

View File

@@ -0,0 +1,27 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import FormattedDate from '../../components/FormattedDate.astro';
import { getCollection } from 'astro:content';
const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<BaseLayout title="Blog" description="Writing and notes by Jean-Luc Makiola." width="narrow">
<h1 class="page-title">Blog</h1>
{posts.length === 0 && <p>No posts yet — check back soon.</p>}
<ul class="post-list">
{
posts.map((post) => (
<li>
<a class="post-card" href={`/blog/${post.id}/`}>
<h3>{post.data.title}</h3>
<FormattedDate date={post.data.pubDate} />
{post.data.description && <p class="excerpt">{post.data.description}</p>}
</a>
</li>
))
}
</ul>
</BaseLayout>

110
src/pages/index.astro Normal file
View File

@@ -0,0 +1,110 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { Icon } from 'astro-icon/components';
import { getCollection } from 'astro:content';
import { SITE } from '../consts';
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
.slice(0, 4);
const fmt = (d: Date) =>
d.toLocaleDateString(SITE.lang, { year: 'numeric', month: 'short' });
const GITEA = 'https://gitea.jeanlucmakiola.de/makiolaj';
// Selected work — pulled from the projects collection (single source of truth;
// per-app pages live at /work/<slug>). Older experiments live on Gitea.
const projects = (await getCollection('projects')).sort(
(a, b) => a.data.order - b.data.order
);
---
<BaseLayout title={SITE.title}>
<!-- Intro: hero + about side by side on wide screens -->
<section class="intro" aria-label="Intro">
<div class="hero intro__lead">
<span class="hero__eyebrow">Developer &amp; designer</span>
<h1>Jean-Luc Makiola<span class="dot">.</span></h1>
<p class="lead">
I build design-led Android apps with Material 3 Expressive on top of
open, self-hostable standards. This is my corner of the web — projects,
writing, and notes.
</p>
<div class="hero__actions">
<a class="btn btn--filled" href="#work">View work</a>
<a class="text-link" href="/blog">Read the blog <span class="dot">→</span></a>
<a class="text-link" href="mailto:mail@jeanlucmakiola.de">
<Icon name="mdi:email-outline" /> Get in touch
</a>
</div>
</div>
<div class="about intro__about" id="about">
<p>
I'd rather put a thoughtful interface on top of an open protocol than
reinvent a sync stack. My apps read and write through the platform's own
providers, so your data stays yours — and stays portable.
</p>
<p class="muted">
They form the <strong>Floret</strong> family: small, focused tools that
share one expressive design language. Everything around them — code,
translations, builds — runs on infrastructure I host myself.
</p>
</div>
</section>
<!-- Selected work -->
<section class="section" id="work">
<div class="section-head">
<h2>Selected work</h2>
<a class="btn btn--text" href="/work">All work →</a>
</div>
<ul class="project-grid">
{
projects.map((p) => (
<li>
<a class="project-card" href={`/work/${p.id}/`}>
<span class={`project-card__status${p.data.released ? ' is-released' : ''}`}>
{p.data.status}
</span>
<span class="project-card__title">
{p.data.name}
<span class="arrow" aria-hidden="true">→</span>
</span>
<p>{p.data.summary}</p>
</a>
</li>
))
}
</ul>
<p class="aside">
Plus a trail of smaller experiments and utilities — most unfinished by
design — over on <a href={GITEA} rel="noopener">my Gitea</a>.
</p>
</section>
<!-- Latest writing -->
{
posts.length > 0 && (
<section class="section">
<div class="section-head">
<h2>Latest writing</h2>
<a class="btn btn--text" href="/blog">All posts →</a>
</div>
<ul class="writing-list">
{posts.map((post) => (
<li>
<a class="writing-row" href={`/blog/${post.id}/`}>
<span class="writing-row__title">{post.data.title}</span>
<time datetime={post.data.pubDate.toISOString()}>
{fmt(post.data.pubDate)}
</time>
</a>
</li>
))}
</ul>
</section>
)
}
</BaseLayout>

21
src/pages/rss.xml.js Normal file
View File

@@ -0,0 +1,21 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import { SITE } from '../consts';
export async function GET(context) {
const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
return rss({
title: SITE.title,
description: SITE.description,
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate,
link: `/blog/${post.id}/`,
})),
});
}

View File

@@ -0,0 +1,41 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import FormattedDate from '../../components/FormattedDate.astro';
import { getCollection } from 'astro:content';
import type { GetStaticPaths } from 'astro';
export const getStaticPaths = (async () => {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const tags = [...new Set(posts.flatMap((p) => p.data.tags))];
return tags.map((tag) => ({
params: { tag },
props: {
tag,
posts: posts
.filter((p) => p.data.tags.includes(tag))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()),
},
}));
}) satisfies GetStaticPaths;
const { tag, posts } = Astro.props;
---
<BaseLayout title={`#${tag}`} description={`Posts tagged ${tag}.`} width="narrow">
<h1 class="page-title">#{tag}</h1>
<ul class="post-list">
{
posts.map((post) => (
<li>
<a class="post-card" href={`/blog/${post.id}/`}>
<h3>{post.data.title}</h3>
<FormattedDate date={post.data.pubDate} />
</a>
</li>
))
}
</ul>
<p style="margin-top: var(--md-sys-spacing-8)">
<a class="btn btn--text" href="/blog">← All posts</a>
</p>
</BaseLayout>

134
src/pages/work/[slug].astro Normal file
View File

@@ -0,0 +1,134 @@
---
import { getCollection, render } from 'astro:content';
import { Icon } from 'astro-icon/components';
import BaseLayout from '../../layouts/BaseLayout.astro';
import type { GetStaticPaths } from 'astro';
export const getStaticPaths = (async () => {
// Build-time: resolve the latest release tag from the project's Gitea repo.
// Never throws — returns null on 404/offline so the build can't break.
// (Defined inside getStaticPaths: Astro extracts this fn into its own scope.)
const fetchLatestRelease = async (repoUrl?: string): Promise<string | null> => {
if (!repoUrl) return null;
try {
const u = new URL(repoUrl);
const [, owner, repo] = u.pathname.split('/');
if (!owner || !repo) return null;
const api = `${u.origin}/api/v1/repos/${owner}/${repo}/releases/latest`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
const res = await fetch(api, {
signal: ctrl.signal,
headers: { Accept: 'application/json' },
});
clearTimeout(timer);
if (!res.ok) return null;
const data = await res.json();
return typeof data.tag_name === 'string' ? data.tag_name : null;
} catch {
return null;
}
};
const projects = await getCollection('projects');
return Promise.all(
projects.map(async (project) => ({
params: { slug: project.id },
props: { project, version: await fetchLatestRelease(project.data.links.gitea) },
}))
);
}) satisfies GetStaticPaths;
const { project, version } = Astro.props;
const { name, status, released, platform, license, summary, tech, features, links } =
project.data;
const { Content } = await render(project);
---
<BaseLayout title={name} description={summary} type="article">
<a class="back-link" href="/work">← All work</a>
<header class="project-detail__head">
<div class="project-detail__status">
<span class={`project-card__status${released ? ' is-released' : ''}`}>
{status}{platform && <span class="sep"> · {platform}</span>}
</span>
{version && <span class="version-badge">{version}</span>}
</div>
<h1>{name}</h1>
<p class="lead">{summary}</p>
<div class="link-row">
{
links.fdroid && (
<a class="btn btn--filled" href={links.fdroid} rel="noopener">
<Icon name="simple-icons:fdroid" /> Get it on F-Droid
</a>
)
}
{
links.gitea && (
<a class="btn btn--outlined" href={links.gitea} rel="noopener">
<Icon name="simple-icons:gitea" /> Source on Gitea
</a>
)
}
{
links.donate && (
<a class="btn btn--outlined" href={links.donate} rel="noopener">
<Icon name="simple-icons:kofi" class="icon-accent" /> Donate
</a>
)
}
{
links.translate && (
<a class="text-link" href={links.translate} rel="noopener">
<Icon name="simple-icons:weblate" /> Help translate
</a>
)
}
</div>
</header>
<div class="project-layout">
<div class="project-main">
<article class="prose project-intro"><Content /></article>
{
features.length > 0 && (
<section class="section" aria-label="Features">
<h2 class="subhead">Features</h2>
<ul class="feature-grid">
{features.map((f) => (
<li class="feature-card">
<h3>{f.title}</h3>
<p>{f.body}</p>
</li>
))}
</ul>
</section>
)
}
</div>
<aside class="project-aside">
<div class="info-card">
<h2 class="subhead">At a glance</h2>
<dl>
<dt>Status</dt>
<dd>{status}</dd>
{platform && (<><dt>Platform</dt><dd>{platform}</dd></>)}
{version && (<><dt>Latest release</dt><dd>{version}</dd></>)}
{license && (<><dt>License</dt><dd>{license}</dd></>)}
</dl>
{
tech.length > 0 && (
<ul class="chip-set tech-chips">
{tech.map((t) => (<li><span class="chip">{t}</span></li>))}
</ul>
)
}
</div>
</aside>
</div>
</BaseLayout>

View File

@@ -0,0 +1,39 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
const GITEA = 'https://gitea.jeanlucmakiola.de/makiolaj';
const projects = (await getCollection('projects')).sort(
(a, b) => a.data.order - b.data.order
);
---
<BaseLayout
title="Work"
description="Projects by Jean-Luc Makiola — the Floret family of Material 3 Expressive Android apps."
>
<h1 class="page-title">Work</h1>
<p class="lead" style="margin-top: calc(-1 * var(--md-sys-spacing-2)); max-width: 54ch">
Intentional, maintained projects — the Floret family. Smaller experiments
live on <a href={GITEA} rel="noopener">my Gitea</a>.
</p>
<ul class="project-grid" style="margin-top: var(--md-sys-spacing-8)">
{
projects.map((p) => (
<li>
<a class="project-card" href={`/work/${p.id}/`}>
<span class={`project-card__status${p.data.released ? ' is-released' : ''}`}>
{p.data.status}
</span>
<span class="project-card__title">
{p.data.name}
<span class="arrow" aria-hidden="true">→</span>
</span>
<p>{p.data.summary}</p>
</a>
</li>
))
}
</ul>
</BaseLayout>

593
src/styles/global.css Normal file
View File

@@ -0,0 +1,593 @@
/*
* Material 3 Expressive — component & layout styles for jeanlucmakiola.de.
* Clean personal-portfolio aesthetic: neutral surfaces, generous whitespace,
* with the brand red as a precise ACCENT (logo dot, links, one button, small
* arrows) — never the dominant surface. Consumes the role tokens in tokens.css.
*/
@import './tokens.css';
/* ---------- reset ---------- */
*,
*::before,
*::after { box-sizing: border-box; }
html {
font-family: var(--md-sys-typescale-brand-font);
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
body {
margin: 0;
min-height: 100dvh;
display: flex;
flex-direction: column;
background: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
font-size: var(--md-sys-typescale-body-large-size);
line-height: var(--md-sys-typescale-body-large-line);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
img { max-width: 100%; height: auto; }
::selection { background: var(--brand-red); color: #fff; }
a {
color: var(--md-sys-color-primary);
text-decoration-color: color-mix(in srgb, var(--md-sys-color-primary) 35%, transparent);
text-underline-offset: 0.18em;
transition: text-decoration-color var(--md-sys-motion-duration-short) var(--md-sys-motion-standard);
}
a:hover { text-decoration-color: currentColor; }
:focus-visible {
outline: 2.5px solid var(--brand-red);
outline-offset: 2px;
border-radius: var(--md-sys-shape-corner-extra-small);
}
/* the signature red dot, reusable */
.dot { color: var(--brand-red); }
/* ---------- layout ---------- */
.container {
width: 100%;
max-width: var(--content-max);
margin-inline: auto;
padding: var(--md-sys-spacing-12) var(--md-sys-spacing-6) var(--md-sys-spacing-8);
flex: 1;
}
/* long-form reading pages stay at a comfortable measure */
.container--narrow { max-width: var(--reading-max); }
/* ============================================================
* Header — quiet, neutral. Typographic logotype with red dot.
* ============================================================ */
.site-header {
position: sticky;
top: 0;
z-index: 10;
background: color-mix(in srgb, var(--md-sys-color-surface) 86%, transparent);
backdrop-filter: saturate(180%) blur(12px);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.site-header__inner {
max-width: var(--wide-max);
margin-inline: auto;
min-height: 60px;
padding: 0 var(--md-sys-spacing-6);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--md-sys-spacing-4);
}
.logotype {
font-size: 1.25rem;
font-weight: 800;
letter-spacing: -0.02em;
text-decoration: none;
color: var(--md-sys-color-on-surface);
}
.site-header nav { display: flex; align-items: center; gap: var(--md-sys-spacing-1); }
.site-header nav a {
color: var(--md-sys-color-on-surface-variant);
text-decoration: none;
font-size: var(--md-sys-typescale-label-large-size);
font-weight: var(--md-sys-typescale-label-large-weight);
padding: var(--md-sys-spacing-2) var(--md-sys-spacing-3);
border-radius: var(--md-sys-shape-corner-full);
transition: color var(--md-sys-motion-duration-short) var(--md-sys-motion-standard),
background var(--md-sys-motion-duration-short) var(--md-sys-motion-standard);
}
.site-header nav a:hover { color: var(--md-sys-color-on-surface); background: var(--md-sys-color-surface-container); }
.site-header nav a[aria-current='page'] { color: var(--md-sys-color-on-surface); }
.site-header nav a[aria-current='page']::after {
content: '';
display: block;
height: 2px;
margin-top: 2px;
border-radius: 2px;
background: var(--brand-red);
}
/* ============================================================
* Hero — clean portfolio intro. No red surfaces; red = name dot + button.
* ============================================================ */
.hero {
padding: var(--md-sys-spacing-8) 0 var(--md-sys-spacing-12);
}
/* two-column intro (hero + about) — fills the wide frame */
.intro {
display: grid;
grid-template-columns: 1.25fr 1fr;
gap: var(--md-sys-spacing-12);
align-items: start;
padding: var(--md-sys-spacing-8) 0 var(--md-sys-spacing-12);
}
.intro .hero { padding: 0; }
.intro__about { padding-top: var(--md-sys-spacing-8); max-width: 42ch; }
@media (max-width: 820px) {
.intro { grid-template-columns: 1fr; gap: var(--md-sys-spacing-8); }
.intro__about { padding-top: 0; }
}
.hero__eyebrow {
display: inline-flex;
align-items: center;
gap: var(--md-sys-spacing-2);
font-size: var(--md-sys-typescale-label-medium-size);
font-weight: var(--md-sys-typescale-label-medium-weight);
letter-spacing: var(--md-sys-typescale-label-medium-tracking);
text-transform: uppercase;
color: var(--md-sys-color-on-surface-variant);
margin-bottom: var(--md-sys-spacing-4);
}
.hero__eyebrow::before {
content: '';
width: 18px;
height: 2px;
border-radius: 2px;
background: var(--brand-red);
}
.hero h1 {
margin: 0;
font-size: var(--md-sys-typescale-display-large-size);
line-height: var(--md-sys-typescale-display-large-line);
font-weight: var(--md-sys-typescale-display-large-weight);
letter-spacing: var(--md-sys-typescale-display-large-tracking);
}
.hero .lead {
max-width: 46ch;
margin: var(--md-sys-spacing-6) 0 0;
font-size: var(--md-sys-typescale-title-medium-size);
font-weight: 400;
line-height: 1.6;
color: var(--md-sys-color-on-surface-variant);
}
.hero__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--md-sys-spacing-3) var(--md-sys-spacing-6);
margin-top: var(--md-sys-spacing-8);
}
.text-link {
display: inline-flex;
align-items: center;
gap: var(--md-sys-spacing-1);
font-size: var(--md-sys-typescale-label-large-size);
font-weight: 600;
color: var(--md-sys-color-on-surface);
text-decoration: none;
}
.text-link:hover { color: var(--md-sys-color-primary); }
/* ============================================================
* Buttons — one filled (red accent), plus tonal/outlined neutrals.
* ============================================================ */
.btn {
display: inline-flex;
align-items: center;
gap: var(--md-sys-spacing-2);
height: 44px;
padding: 0 var(--md-sys-spacing-6);
border: none;
border-radius: var(--md-sys-shape-corner-full);
font: inherit;
font-size: var(--md-sys-typescale-label-large-size);
font-weight: var(--md-sys-typescale-label-large-weight);
text-decoration: none;
cursor: pointer;
transition: box-shadow var(--md-sys-motion-duration-short) var(--md-sys-motion-standard),
transform var(--md-sys-motion-duration-short) var(--md-sys-motion-emphasized),
background var(--md-sys-motion-duration-short) var(--md-sys-motion-standard);
}
.btn:active { transform: scale(0.97); }
.btn--filled { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); }
.btn--filled:hover { box-shadow: var(--md-sys-elevation-2); }
.btn--outlined {
background: transparent;
color: var(--md-sys-color-on-surface);
border: 1px solid var(--md-sys-color-outline);
}
.btn--outlined:hover { background: var(--md-sys-color-surface-container); }
.btn--text { height: auto; padding: var(--md-sys-spacing-2) var(--md-sys-spacing-2); background: transparent; color: var(--md-sys-color-primary); }
.btn--text:hover { background: color-mix(in srgb, var(--md-sys-color-primary) 8%, transparent); }
/* ============================================================
* Section scaffolding
* ============================================================ */
.section { margin-top: var(--md-sys-spacing-16); }
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--md-sys-spacing-4);
margin-bottom: var(--md-sys-spacing-6);
}
.section-head h2 {
margin: 0;
font-size: var(--md-sys-typescale-headline-medium-size);
font-weight: var(--md-sys-typescale-headline-medium-weight);
letter-spacing: var(--md-sys-typescale-headline-medium-tracking);
}
/* ============================================================
* About — a short, readable statement
* ============================================================ */
.about { max-width: 54ch; }
.about p {
margin: 0 0 var(--md-sys-spacing-4);
font-size: var(--md-sys-typescale-title-medium-size);
line-height: 1.65;
}
.about p:last-child { margin-bottom: 0; }
.about .muted { color: var(--md-sys-color-on-surface-variant); }
.about strong { color: var(--md-sys-color-on-surface); font-weight: 700; }
/* small note under a section */
.aside {
margin: var(--md-sys-spacing-6) 0 0;
font-size: var(--md-sys-typescale-body-medium-size);
color: var(--md-sys-color-on-surface-variant);
}
/* ============================================================
* Project cards (Selected work)
* ============================================================ */
.project-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
/* auto-fit so a few cards stretch to fill the wider frame */
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--md-sys-spacing-4);
}
.project-card {
display: flex;
flex-direction: column;
gap: var(--md-sys-spacing-2);
height: 100%;
padding: var(--md-sys-spacing-6);
border-radius: var(--md-sys-shape-corner-large);
background: var(--md-sys-color-surface-container-low);
border: 1px solid var(--md-sys-color-outline-variant);
text-decoration: none;
color: inherit;
transition: border-color var(--md-sys-motion-duration-short) var(--md-sys-motion-standard),
transform var(--md-sys-motion-duration-medium) var(--md-sys-motion-emphasized),
box-shadow var(--md-sys-motion-duration-medium) var(--md-sys-motion-emphasized);
}
.project-card:hover {
transform: translateY(-3px);
box-shadow: var(--md-sys-elevation-2);
border-color: var(--brand-red);
}
.project-card__status {
display: inline-flex;
align-items: center;
gap: var(--md-sys-spacing-2);
font-size: var(--md-sys-typescale-label-medium-size);
font-weight: var(--md-sys-typescale-label-medium-weight);
letter-spacing: var(--md-sys-typescale-label-medium-tracking);
text-transform: uppercase;
color: var(--md-sys-color-on-surface-variant);
}
.project-card__status.is-released::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--brand-red);
}
.project-card__title {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--md-sys-spacing-1);
font-size: var(--md-sys-typescale-title-large-size);
font-weight: var(--md-sys-typescale-title-large-weight);
}
.project-card__title .arrow {
color: var(--brand-red);
transition: transform var(--md-sys-motion-duration-short) var(--md-sys-motion-emphasized);
}
.project-card:hover .arrow { transform: translateX(3px); }
.project-card p {
margin: 0;
font-size: var(--md-sys-typescale-body-medium-size);
color: var(--md-sys-color-on-surface-variant);
}
/* ============================================================
* Project detail page (/work/<slug>)
* ============================================================ */
.back-link {
display: inline-block;
margin-bottom: var(--md-sys-spacing-6);
font-size: var(--md-sys-typescale-label-large-size);
font-weight: 600;
color: var(--md-sys-color-on-surface-variant);
text-decoration: none;
}
.back-link:hover { color: var(--md-sys-color-primary); }
.project-detail__head {
margin-bottom: var(--md-sys-spacing-8);
padding-bottom: var(--md-sys-spacing-8);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.project-detail__head h1 {
margin: var(--md-sys-spacing-3) 0 0;
font-size: var(--md-sys-typescale-display-medium-size);
line-height: var(--md-sys-typescale-display-medium-line);
font-weight: var(--md-sys-typescale-display-medium-weight);
letter-spacing: var(--md-sys-typescale-display-medium-tracking);
}
.project-detail__head .lead {
max-width: 54ch;
margin: var(--md-sys-spacing-4) 0 0;
font-size: var(--md-sys-typescale-title-medium-size);
color: var(--md-sys-color-on-surface-variant);
}
.project-card__status .sep { color: var(--md-sys-color-outline); }
.project-detail__status {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--md-sys-spacing-3);
}
.version-badge {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 var(--md-sys-spacing-2);
border-radius: var(--md-sys-shape-corner-extra-small);
background: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
font-family: var(--md-sys-typescale-mono-font);
font-size: 0.75rem;
font-weight: 600;
}
.link-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--md-sys-spacing-3);
margin-top: var(--md-sys-spacing-6);
}
/* icons inside buttons / links */
.btn svg,
.text-link svg,
.socials a svg { width: 1.15em; height: 1.15em; flex-shrink: 0; }
.icon-accent { color: var(--brand-red); }
/* two-column detail layout */
.project-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 16rem;
gap: var(--md-sys-spacing-12);
margin-top: var(--md-sys-spacing-8);
align-items: start;
}
.project-intro { font-size: var(--md-sys-typescale-body-large-size); }
.project-main .section { margin-top: var(--md-sys-spacing-12); }
.subhead {
margin: 0 0 var(--md-sys-spacing-4);
font-size: var(--md-sys-typescale-title-large-size);
font-weight: var(--md-sys-typescale-title-large-weight);
letter-spacing: -0.01em;
}
/* feature grid */
.feature-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--md-sys-spacing-3);
}
.feature-card {
padding: var(--md-sys-spacing-5, 20px) var(--md-sys-spacing-5, 20px);
padding: var(--md-sys-spacing-6);
border-radius: var(--md-sys-shape-corner-large);
background: var(--md-sys-color-surface-container-low);
border: 1px solid var(--md-sys-color-outline-variant);
}
.feature-card h3 {
margin: 0 0 var(--md-sys-spacing-2);
font-size: var(--md-sys-typescale-title-medium-size);
font-weight: 600;
}
.feature-card p {
margin: 0;
font-size: var(--md-sys-typescale-body-medium-size);
color: var(--md-sys-color-on-surface-variant);
}
/* aside: at-a-glance panel (sticky on wide screens) */
.project-aside { position: sticky; top: 80px; }
.info-card {
padding: var(--md-sys-spacing-6);
border-radius: var(--md-sys-shape-corner-large);
background: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-outline-variant);
}
.info-card .subhead { font-size: var(--md-sys-typescale-title-medium-size); }
.info-card dl { margin: 0; display: grid; grid-template-columns: 1fr; gap: var(--md-sys-spacing-3); }
.info-card dt {
font-size: var(--md-sys-typescale-label-medium-size);
letter-spacing: var(--md-sys-typescale-label-medium-tracking);
text-transform: uppercase;
color: var(--md-sys-color-on-surface-variant);
}
.info-card dd { margin: 2px 0 0; font-weight: 600; }
.tech-chips { margin-top: var(--md-sys-spacing-6); }
.tech-chips .chip { height: 28px; font-size: var(--md-sys-typescale-label-medium-size); }
@media (max-width: 820px) {
.project-layout { grid-template-columns: 1fr; gap: var(--md-sys-spacing-8); }
.project-aside { position: static; order: -1; }
}
/* ============================================================
* Writing list — quiet rows with a divider, not heavy cards.
* ============================================================ */
.writing-list { list-style: none; margin: 0; padding: 0; }
.writing-list li { border-top: 1px solid var(--md-sys-color-outline-variant); }
.writing-list li:last-child { border-bottom: 1px solid var(--md-sys-color-outline-variant); }
.writing-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--md-sys-spacing-4);
padding: var(--md-sys-spacing-4) var(--md-sys-spacing-2);
text-decoration: none;
color: inherit;
transition: padding-inline-start var(--md-sys-motion-duration-short) var(--md-sys-motion-emphasized),
color var(--md-sys-motion-duration-short) var(--md-sys-motion-standard);
}
.writing-row:hover { padding-inline-start: var(--md-sys-spacing-4); color: var(--md-sys-color-primary); }
.writing-row__title { font-size: var(--md-sys-typescale-title-medium-size); font-weight: 600; }
.writing-row time {
flex-shrink: 0;
font-size: var(--md-sys-typescale-label-medium-size);
letter-spacing: var(--md-sys-typescale-label-medium-tracking);
text-transform: uppercase;
color: var(--md-sys-color-on-surface-variant);
}
/* ============================================================
* Blog list (the /blog index) — cards
* ============================================================ */
.post-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--md-sys-spacing-4); }
.post-card {
display: block;
padding: var(--md-sys-spacing-6);
border-radius: var(--md-sys-shape-corner-large);
background: var(--md-sys-color-surface-container-low);
border: 1px solid var(--md-sys-color-outline-variant);
text-decoration: none;
color: inherit;
transition: border-color var(--md-sys-motion-duration-short) var(--md-sys-motion-standard),
transform var(--md-sys-motion-duration-medium) var(--md-sys-motion-emphasized),
box-shadow var(--md-sys-motion-duration-medium) var(--md-sys-motion-emphasized);
}
.post-card:hover { transform: translateY(-2px); box-shadow: var(--md-sys-elevation-2); border-color: var(--brand-red); }
.post-card h3 { margin: 0; font-size: var(--md-sys-typescale-title-large-size); font-weight: var(--md-sys-typescale-title-large-weight); }
.post-card time {
display: block; margin-top: var(--md-sys-spacing-1);
font-size: var(--md-sys-typescale-label-medium-size);
letter-spacing: var(--md-sys-typescale-label-medium-tracking);
text-transform: uppercase;
color: var(--md-sys-color-on-surface-variant);
}
.post-card .excerpt { margin: var(--md-sys-spacing-3) 0 0; font-size: var(--md-sys-typescale-body-medium-size); color: var(--md-sys-color-on-surface-variant); }
/* ============================================================
* Chips (tags)
* ============================================================ */
.chip-set { list-style: none; margin: var(--md-sys-spacing-4) 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--md-sys-spacing-2); }
.chip {
display: inline-flex; align-items: center; height: 32px; padding: 0 var(--md-sys-spacing-4);
border-radius: var(--md-sys-shape-corner-small);
border: 1px solid var(--md-sys-color-outline-variant);
background: transparent;
color: var(--md-sys-color-on-surface-variant);
font-size: var(--md-sys-typescale-label-large-size); font-weight: 500; text-decoration: none;
transition: background var(--md-sys-motion-duration-short) var(--md-sys-motion-standard);
}
.chip:hover { background: var(--md-sys-color-surface-container-high); color: var(--md-sys-color-on-surface); }
/* ============================================================
* Article / prose
* ============================================================ */
.page-title, .post-header h1 {
font-size: var(--md-sys-typescale-headline-large-size);
font-weight: var(--md-sys-typescale-headline-large-weight);
letter-spacing: var(--md-sys-typescale-headline-large-tracking);
margin: 0 0 var(--md-sys-spacing-6);
}
.post-header { margin-bottom: var(--md-sys-spacing-8); }
.post-header h1 { margin-bottom: var(--md-sys-spacing-3); }
.post-meta { color: var(--md-sys-color-on-surface-variant); font-size: var(--md-sys-typescale-label-large-size); }
.prose { font-size: var(--md-sys-typescale-body-large-size); }
.prose :is(h2, h3) { margin-top: var(--md-sys-spacing-12); margin-bottom: var(--md-sys-spacing-3); letter-spacing: -0.01em; }
.prose h2 { font-size: var(--md-sys-typescale-headline-medium-size); }
.prose h3 { font-size: var(--md-sys-typescale-title-large-size); }
.prose p { margin: var(--md-sys-spacing-4) 0; }
.prose code {
font-family: var(--md-sys-typescale-mono-font); font-size: 0.9em;
background: var(--md-sys-color-surface-container-high); padding: 0.15em 0.4em;
border-radius: var(--md-sys-shape-corner-extra-small);
}
.prose pre {
font-family: var(--md-sys-typescale-mono-font); padding: var(--md-sys-spacing-4);
border-radius: var(--md-sys-shape-corner-large);
background: var(--md-sys-color-surface-container-high);
border: 1px solid var(--md-sys-color-outline-variant); overflow-x: auto;
}
.prose pre code { background: none; padding: 0; }
.prose blockquote {
margin-inline: 0; padding: var(--md-sys-spacing-2) var(--md-sys-spacing-6);
border-left: 3px solid var(--brand-red); color: var(--md-sys-color-on-surface-variant);
}
.prose img { border-radius: var(--md-sys-shape-corner-large); }
/* ============================================================
* Footer
* ============================================================ */
.site-footer { margin-top: var(--md-sys-spacing-16); border-top: 1px solid var(--md-sys-color-outline-variant); color: var(--md-sys-color-on-surface-variant); }
.site-footer__inner {
max-width: var(--wide-max); margin-inline: auto;
padding: var(--md-sys-spacing-8) var(--md-sys-spacing-6);
display: flex; align-items: center; justify-content: space-between;
gap: var(--md-sys-spacing-4); flex-wrap: wrap;
font-size: var(--md-sys-typescale-body-medium-size);
}
.site-footer .socials { display: flex; flex-wrap: wrap; gap: var(--md-sys-spacing-4); }
.site-footer .socials a {
display: inline-flex;
align-items: center;
gap: var(--md-sys-spacing-2);
text-decoration: none;
}
.site-footer .socials a:hover { color: var(--md-sys-color-primary); }
.site-footer a { color: var(--md-sys-color-on-surface); }
/* ---------- responsive ---------- */
@media (max-width: 599px) {
.container { padding: var(--md-sys-spacing-8) var(--md-sys-spacing-4) var(--md-sys-spacing-6); }
.app-bar__inner, .site-header__inner { padding-inline: var(--md-sys-spacing-4); }
.section { margin-top: var(--md-sys-spacing-12); }
}

226
src/styles/tokens.css Normal file
View File

@@ -0,0 +1,226 @@
/*
* Material 3 Expressive — design tokens for jeanlucmakiola.de
* ----------------------------------------------------------------------------
* Seeded from the brand red #FF4242 (the "JLM." dot, the Gitea navbar).
* Color roles follow the M3 `md.sys.color` spec, mapped to CSS custom props,
* so the whole site themes from these roles — never hardcode hex in components.
*
* The brand is RED + MONOCHROME (red #FF4242, ink #242424, gray #BDBDBD, white).
* The pine-silhouette asset is grayscale fog — NOT green. So no foreign hues:
*
* primary = red tonal palette (brand) seed #FF4242
* secondary = warm rosy-neutral (red-adjacent, low chroma)
* tertiary = warm charcoal/gray (keeps the accent monochrome, not colorful)
* --brand-red = the literal vivid #FF4242, for the signature accent moments
*
* Tonal pairing rule: only put `on-X` text on an `X` surface.
*/
:root {
/* ---- Brand literal (the signature vivid red; large/bold usage) ---- */
--brand-red: #ff4242;
--brand-ink: #242424;
/* ===================== COLOR · LIGHT ===================== */
--md-sys-color-primary: #bb1614;
--md-sys-color-on-primary: #ffffff;
--md-sys-color-primary-container: #ffdad5;
--md-sys-color-on-primary-container: #410001;
--md-sys-color-secondary: #5b5957;
--md-sys-color-on-secondary: #ffffff;
--md-sys-color-secondary-container: #e8e5e3;
--md-sys-color-on-secondary-container: #1c1b1a;
--md-sys-color-tertiary: #4a4543;
--md-sys-color-on-tertiary: #ffffff;
--md-sys-color-tertiary-container: #e9e1de;
--md-sys-color-on-tertiary-container: #1b1a19;
--md-sys-color-error: #ba1a1a;
--md-sys-color-on-error: #ffffff;
--md-sys-color-error-container: #ffdad6;
--md-sys-color-on-error-container: #410002;
--md-sys-color-surface: #fbfaf9;
--md-sys-color-on-surface: #1c1b1a;
--md-sys-color-on-surface-variant: #4b4847;
--md-sys-color-surface-variant: #e7e4e2;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #f5f3f2;
--md-sys-color-surface-container: #efedeb;
--md-sys-color-surface-container-high: #e9e7e5;
--md-sys-color-surface-container-highest: #e3e1df;
--md-sys-color-surface-dim: #ddd9d7;
--md-sys-color-surface-bright: #fbfaf9;
--md-sys-color-outline: #7b7775;
--md-sys-color-outline-variant: #cdc9c6;
--md-sys-color-inverse-surface: #392e2c;
--md-sys-color-inverse-on-surface: #ffede9;
--md-sys-color-inverse-primary: #ffb4ab;
--md-sys-color-shadow: #000000;
--md-sys-color-scrim: #000000;
/* Header / app bar in brand red (matches the Gitea theme pattern) */
--brand-bar-bg: var(--brand-red);
--brand-bar-on: #ffffff;
/* ===================== TYPOGRAPHY ===================== */
/* Inter (proportional) + JetBrains Mono (code) — self-hosted via Fontsource */
--md-sys-typescale-brand-font: 'Inter Variable', Inter, system-ui, -apple-system,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--md-sys-typescale-mono-font: 'JetBrains Mono Variable', 'JetBrains Mono',
ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* Display */
--md-sys-typescale-display-large-size: clamp(2.75rem, 6vw, 3.75rem);
--md-sys-typescale-display-large-line: 1.08;
--md-sys-typescale-display-large-weight: 700;
--md-sys-typescale-display-large-tracking: -0.02em;
--md-sys-typescale-display-medium-size: clamp(2.25rem, 4.5vw, 2.8rem);
--md-sys-typescale-display-medium-line: 1.12;
--md-sys-typescale-display-medium-weight: 700;
--md-sys-typescale-display-medium-tracking: -0.015em;
/* Headline */
--md-sys-typescale-headline-large-size: 2rem;
--md-sys-typescale-headline-large-line: 1.2;
--md-sys-typescale-headline-large-weight: 650;
--md-sys-typescale-headline-large-tracking: -0.01em;
--md-sys-typescale-headline-medium-size: 1.5rem;
--md-sys-typescale-headline-medium-line: 1.25;
--md-sys-typescale-headline-medium-weight: 650;
--md-sys-typescale-headline-medium-tracking: -0.005em;
/* Title */
--md-sys-typescale-title-large-size: 1.25rem;
--md-sys-typescale-title-large-line: 1.3;
--md-sys-typescale-title-large-weight: 600;
--md-sys-typescale-title-medium-size: 1rem;
--md-sys-typescale-title-medium-line: 1.4;
--md-sys-typescale-title-medium-weight: 600;
--md-sys-typescale-title-medium-tracking: 0.01em;
/* Body */
--md-sys-typescale-body-large-size: 1.0625rem;
--md-sys-typescale-body-large-line: 1.65;
--md-sys-typescale-body-large-weight: 400;
--md-sys-typescale-body-medium-size: 0.9375rem;
--md-sys-typescale-body-medium-line: 1.6;
--md-sys-typescale-body-medium-weight: 400;
/* Label */
--md-sys-typescale-label-large-size: 0.875rem;
--md-sys-typescale-label-large-weight: 600;
--md-sys-typescale-label-large-tracking: 0.01em;
--md-sys-typescale-label-medium-size: 0.75rem;
--md-sys-typescale-label-medium-weight: 600;
--md-sys-typescale-label-medium-tracking: 0.04em;
/* ===================== SHAPE (Expressive) ===================== */
--md-sys-shape-corner-none: 0;
--md-sys-shape-corner-extra-small: 4px;
--md-sys-shape-corner-small: 8px;
--md-sys-shape-corner-medium: 12px;
--md-sys-shape-corner-large: 16px;
--md-sys-shape-corner-large-increased: 20px;
--md-sys-shape-corner-extra-large: 28px;
--md-sys-shape-corner-extra-large-increased: 32px;
--md-sys-shape-corner-extra-extra-large: 48px;
--md-sys-shape-corner-full: 9999px;
/* ===================== SPACING (8dp system) ===================== */
--md-sys-spacing-1: 4px;
--md-sys-spacing-2: 8px;
--md-sys-spacing-3: 12px;
--md-sys-spacing-4: 16px;
--md-sys-spacing-6: 24px;
--md-sys-spacing-8: 32px;
--md-sys-spacing-12: 48px;
--md-sys-spacing-16: 64px;
/* ===================== ELEVATION (tonal + soft shadow) ===================== */
--md-sys-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 3px 1px rgba(0, 0, 0, 0.04);
--md-sys-elevation-2: 0 1px 2px rgba(0, 0, 0, 0.08), 0 2px 6px 2px rgba(0, 0, 0, 0.05);
--md-sys-elevation-3: 0 4px 8px 3px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.1);
/* ===================== MOTION ===================== */
--md-sys-motion-emphasized: cubic-bezier(0.2, 0, 0, 1);
--md-sys-motion-emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1);
--md-sys-motion-emphasized-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15);
--md-sys-motion-standard: cubic-bezier(0.2, 0, 0, 1);
--md-sys-motion-duration-short: 200ms;
--md-sys-motion-duration-medium: 350ms;
--md-sys-motion-duration-long: 500ms;
/* layout */
--content-max: 72rem; /* default frame: homepage, work, header, footer */
--wide-max: 72rem;
--reading-max: 48rem; /* long-form pages: blog, posts, tags */
color-scheme: light;
}
/* ===================== COLOR · DARK ===================== */
@media (prefers-color-scheme: dark) {
:root {
--md-sys-color-primary: #ffb4ab;
--md-sys-color-on-primary: #690002;
--md-sys-color-primary-container: #930007;
--md-sys-color-on-primary-container: #ffdad5;
--md-sys-color-secondary: #c9c5c2;
--md-sys-color-on-secondary: #2f2e2c;
--md-sys-color-secondary-container: #444240;
--md-sys-color-on-secondary-container: #e8e5e3;
--md-sys-color-tertiary: #cdc5c2;
--md-sys-color-on-tertiary: #302c2b;
--md-sys-color-tertiary-container: #474240;
--md-sys-color-on-tertiary-container: #e9e1de;
--md-sys-color-error: #ffb4ab;
--md-sys-color-on-error: #690005;
--md-sys-color-error-container: #93000a;
--md-sys-color-on-error-container: #ffdad6;
--md-sys-color-surface: #131312;
--md-sys-color-on-surface: #e6e2e0;
--md-sys-color-on-surface-variant: #c8c4c2;
--md-sys-color-surface-variant: #464442;
--md-sys-color-surface-container-lowest: #0e0e0d;
--md-sys-color-surface-container-low: #1b1b1a;
--md-sys-color-surface-container: #1f1f1e;
--md-sys-color-surface-container-high: #2a2928;
--md-sys-color-surface-container-highest: #353433;
--md-sys-color-surface-dim: #131312;
--md-sys-color-surface-bright: #3a3938;
--md-sys-color-outline: #948f8d;
--md-sys-color-outline-variant: #484644;
--md-sys-color-inverse-surface: #f0dedb;
--md-sys-color-inverse-on-surface: #392e2c;
--md-sys-color-inverse-primary: #bb1614;
/* Keep the brand-red bar; it reads well on dark too */
--brand-bar-bg: var(--brand-red);
--brand-bar-on: #ffffff;
--md-sys-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.4);
--md-sys-elevation-2: 0 2px 6px rgba(0, 0, 0, 0.45);
--md-sys-elevation-3: 0 6px 14px rgba(0, 0, 0, 0.5);
color-scheme: dark;
}
}