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

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
.astro
.git
.env
.env.production
*.log

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# build output
dist/
# generated types
.astro/
# deps
node_modules/
# env
.env
.env.production
# os / editor
.DS_Store
*.log
# local screenshots
.shots/

16
Caddyfile Normal file
View File

@@ -0,0 +1,16 @@
# Caddy serves the static build inside the container on :80.
# Coolify's proxy terminates TLS and handles the public domain.
:80 {
root * /srv
encode zstd gzip
file_server
# Astro builds "/foo" -> "/foo/index.html"; try clean URLs first.
try_files {path} {path}/ {path}.html /404.html
# Long-cache fingerprinted assets, revalidate HTML.
@assets path /_astro/*
header @assets Cache-Control "public, max-age=31536000, immutable"
@html path *.html /
header @html Cache-Control "public, max-age=0, must-revalidate"
}

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# --- Build stage: produce static dist/ ---
FROM node:22-alpine AS build
WORKDIR /app
# Install deps against the lockfile for reproducible builds.
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
# PUBLIC_* env vars are inlined at build time. In Coolify, set them as
# build-time variables so analytics is baked into the static output.
RUN npm run build
# --- Runtime stage: serve dist/ with Caddy ---
FROM caddy:2-alpine AS runtime
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv
EXPOSE 80

81
PLANNING.md Normal file
View File

@@ -0,0 +1,81 @@
# jeanlucmakiola.de — personal website
Status: **discussion / not started** · Started 2026-06-28
## Goal
A simple personal website (with room to grow into a blog), self-hosted on
Coolify, privacy-respecting analytics, solid SEO, and an AI-driven authoring
flow (content written as files by an agent, reviewed via git diff, auto-deployed
on push).
## Hard requirements
- Self-hosted deploy on **Coolify** (own infra)
- Privacy-respecting **analytics** (Umami preferred)
- **SEO**: sitemap, RSS, canonical URLs, Open Graph, fast/static
- **AI-driven flow**: agent can author/edit content via CLI/files (and/or API)
- Keep it simple; nothing crazy. Blog is a likely-but-later addition.
## Recommended stack (proposed)
| Concern | Choice | Why |
|----------------|------------------------------------------|-----|
| Framework | **Astro** (static output) | Content-first, zero-JS default, content collections, great SEO |
| Content | **Markdown/MDX in git** (`src/content/`) | AI writes files; git is the API; no lock-in |
| Analytics | **Umami**, self-hosted on Coolify | Privacy-respecting, cookieless, one `<script>` tag |
| SEO | `@astrojs/sitemap` + `@astrojs/rss` + OG | Mostly batteries-included |
| Deploy | Coolify static site (Docker/Nixpacks → `dist/`, served by Caddy/nginx) | Auto-deploy on git push |
| Design | TBD — align with Floret/M3 leanings later | per design-workflow rule, consult ui-ux-pro-max before building UI |
## AI authoring loop
1. Agent writes/edits `src/content/blog/<slug>.md` (frontmatter: title, date, description, tags)
2. User reviews `git diff`
3. Commit + push → Coolify rebuilds → live
## Decisions (locked 2026-06-28)
- [x] Framework: **Astro** (static)
- [x] Content authoring: **Markdown-in-git**
- [x] Analytics: **Umami**, self-hosted on Coolify
- [ ] Domain confirmed: jeanlucmakiola.de
- [ ] Scope of v1: homepage only, or homepage + blog from the start
## Build plan (v1)
1. [x] Scaffold Astro project (hand-rolled, Astro 7, strict TS)
2. [x] Base layout + SEO head (title/description/canonical/OG), global styles (neutral)
3. [x] Homepage (intro + latest posts)
4. [x] Blog: content collection + index page + post template + tag pages
5. [x] `@astrojs/sitemap` + RSS feed + robots.txt
6. [x] Umami script in base layout (env-gated so it's off in dev)
7. [x] Dockerfile (build → static, served by Caddy) + Caddyfile
8. [x] **Visual design pass** — M3 Expressive, brand-faithful (see below)
9. [ ] Add `og-default.png` (1200×630) to `public/`
10. [ ] Deploy Umami as a Coolify service (one-click template)
11. [ ] Deploy site on Coolify (Dockerfile build); set `PUBLIC_UMAMI_*` build vars
12. [ ] Point jeanlucmakiola.de DNS at Coolify; enable HTTPS
## Design system (M3 Expressive)
- Tokens in `src/styles/tokens.css` as `--md-sys-*` CSS custom properties
(color roles, type scale, shape, elevation, motion, 8dp spacing); light + dark.
- **Brand = red + monochrome.** `#FF4242` is an ACCENT only (logo dot, name
period, eyebrow tick, active-nav underline, one filled button, card arrows,
links). Surfaces are NEUTRAL gray — earlier red-tinted/red-bar version was
rejected as "too red heavy".
- The pine-silhouette asset is grayscale (not green) — confirmed; no green hue.
tertiary role is a warm neutral, not a color.
- Fonts: **Inter** + **JetBrains Mono**, self-hosted via Fontsource (no Google CDN).
- Homepage is a clean portfolio: hero → About → Selected work cards → Latest writing rows.
- Favicon = the rounded red "JM." mark; header is a typographic `JLM.` logotype.
- **Projects** are a content collection (`src/content/projects/*.md`, schema in
`content.config.ts`): frontmatter (name, order, status, released, platform,
summary, links{gitea,fdroid,donate,translate}) + Markdown body. Single source
of truth for both the homepage cards and `/work`.
- `/work` index lists all apps; `/work/<slug>` is a per-app page with its own
link set (F-Droid/Gitea/Donate/Translate, shown only when present) + overview.
Currently: Calendula (released), Agendula (in dev), floret-kit (design system).
## Verified
- `npm install` + `npm run build` succeed; 4 pages + RSS + sitemap generated.
- Astro 7.0.3, 0 npm audit vulnerabilities (2026-06-28).
- Screenshotted home/blog/post desktop + mobile (DARK mode). Light mode not yet
visually verified — headless couldn't force the scheme — but tokens are standard M3.
## Notes
- Coolify supports git-push auto-deploy; Umami has an official Docker image deployable as its own Coolify service.

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
# jeanlucmakiola.de
Personal website + blog. Built with [Astro](https://astro.build), deployed on
self-hosted [Coolify](https://coolify.io), analytics via self-hosted
[Umami](https://umami.is).
See [PLANNING.md](./PLANNING.md) for the decisions and roadmap.
## Develop
```sh
npm install
npm run dev # http://localhost:4321
npm run build # output -> dist/
npm run preview # serve the built site locally
```
## Writing a post
Create a Markdown file in `src/content/blog/`, e.g. `my-post.md`:
```md
---
title: My post
description: One-line summary (used for SEO + RSS).
pubDate: 2026-06-28
tags: [notes]
draft: false # set true to hide from listings/feeds
---
Body in Markdown.
```
The filename (minus extension) becomes the URL slug: `/blog/my-post/`.
Commit + push → Coolify rebuilds → live.
## Analytics (Umami)
Analytics is **off** unless these env vars are set (so it stays off in dev).
Set them as **build-time** variables in Coolify:
| Variable | Value |
|-------------------------|-----------------------------------------|
| `PUBLIC_UMAMI_SRC` | `https://<your-umami>/script.js` |
| `PUBLIC_UMAMI_WEBSITE` | the website id (UUID) from Umami |
## Deploy on Coolify
This repo ships a multi-stage `Dockerfile` (build with Node → serve `dist/`
with Caddy on port 80).
1. New Resource → **Dockerfile** (or auto-detect), point at this repo.
2. Set the two `PUBLIC_UMAMI_*` build variables.
3. Set the domain to `jeanlucmakiola.de`; Coolify provisions HTTPS.
4. Deploy Umami separately (Coolify has a one-click Umami template), then copy
its script URL + website id into the build variables above.
## SEO
- Sitemap at `/sitemap-index.xml` (via `@astrojs/sitemap`)
- RSS at `/rss.xml`
- Canonical URLs + Open Graph/Twitter tags in `src/components/BaseHead.astro`
- `robots.txt` in `public/`
- Add an `og-default.png` (1200×630) to `public/` for link previews.

12
astro.config.mjs Normal file
View File

@@ -0,0 +1,12 @@
// @ts-check
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import icon from 'astro-icon';
import { SITE } from './src/consts';
// https://astro.build/config
export default defineConfig({
// Used for sitemap, RSS, and canonical/OG absolute URLs. Override per-env if needed.
site: SITE.url,
integrations: [sitemap(), icon()],
});

5069
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "jeanlucmakiola.de",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.3",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/simple-icons": "^1.2.87",
"astro": "^7.0.3",
"astro-icon": "^1.1.5"
}
}

View File

@@ -0,0 +1,6 @@
<svg width="1034" height="1034" viewBox="0 0 1034 1034" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 517C0 231.469 231.469 0 517 0C802.531 0 1034 231.469 1034 517C1034 802.531 802.531 1034 517 1034C231.469 1034 0 802.531 0 517Z" fill="#242424"/>
<path d="M230.249 334.364H316.896V585.784C316.778 609.34 311.155 629.937 300.028 647.574C288.902 665.093 273.513 678.706 253.864 688.412C234.332 698.118 211.723 702.972 186.037 702.972C163.31 702.972 142.654 699.006 124.07 691.075C105.604 683.144 90.8665 670.834 79.858 654.143C68.9678 637.453 63.5819 616.087 63.7003 590.045H151.236C151.591 599.397 153.366 607.387 156.562 614.016C159.877 620.526 164.434 625.438 170.234 628.753C176.035 632.067 182.959 633.724 191.009 633.724C199.413 633.724 206.515 631.949 212.315 628.398C218.116 624.728 222.495 619.342 225.455 612.24C228.532 605.138 230.13 596.319 230.249 585.784V334.364Z" fill="white"/>
<path d="M382.072 334.364H490.914L583.244 559.506H587.505L679.835 334.364H788.677V698H703.095V474.634H700.076L612.718 695.692H558.031L470.673 473.391H467.654V698H382.072V334.364Z" fill="white"/>
<path d="M902.326 703.149C889.542 703.149 878.593 698.651 869.478 689.655C860.482 680.659 856.043 669.768 856.161 656.984C856.043 644.437 860.482 633.724 869.478 624.847C878.593 615.85 889.542 611.352 902.326 611.352C914.4 611.352 925.053 615.85 934.286 624.847C943.638 633.724 948.372 644.437 948.491 656.984C948.372 665.507 946.123 673.26 941.744 680.244C937.482 687.228 931.86 692.792 924.876 696.935C918.01 701.078 910.494 703.149 902.326 703.149Z" fill="#FF4242"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

6
public/favicon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="1034" height="1034" viewBox="0 0 1034 1034" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 517C0 231.469 231.469 0 517 0C802.531 0 1034 231.469 1034 517C1034 802.531 802.531 1034 517 1034C231.469 1034 0 802.531 0 517Z" fill="#242424"/>
<path d="M230.249 334.364H316.896V585.784C316.778 609.34 311.155 629.937 300.028 647.574C288.902 665.093 273.513 678.706 253.864 688.412C234.332 698.118 211.723 702.972 186.037 702.972C163.31 702.972 142.654 699.006 124.07 691.075C105.604 683.144 90.8665 670.834 79.858 654.143C68.9678 637.453 63.5819 616.087 63.7003 590.045H151.236C151.591 599.397 153.366 607.387 156.562 614.016C159.877 620.526 164.434 625.438 170.234 628.753C176.035 632.067 182.959 633.724 191.009 633.724C199.413 633.724 206.515 631.949 212.315 628.398C218.116 624.728 222.495 619.342 225.455 612.24C228.532 605.138 230.13 596.319 230.249 585.784V334.364Z" fill="white"/>
<path d="M382.072 334.364H490.914L583.244 559.506H587.505L679.835 334.364H788.677V698H703.095V474.634H700.076L612.718 695.692H558.031L470.673 473.391H467.654V698H382.072V334.364Z" fill="white"/>
<path d="M902.326 703.149C889.542 703.149 878.593 698.651 869.478 689.655C860.482 680.659 856.043 669.768 856.161 656.984C856.043 644.437 860.482 633.724 869.478 624.847C878.593 615.85 889.542 611.352 902.326 611.352C914.4 611.352 925.053 615.85 934.286 624.847C943.638 633.724 948.372 644.437 948.491 656.984C948.372 665.507 946.123 673.26 941.744 680.244C937.482 687.228 931.86 692.792 924.876 696.935C918.01 701.078 910.494 703.149 902.326 703.149Z" fill="#FF4242"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

4
public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://jeanlucmakiola.de/sitemap-index.xml

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;
}
}

5
tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}