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:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
*.log
|
||||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
16
Caddyfile
Normal 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
18
Dockerfile
Normal 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
81
PLANNING.md
Normal 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
64
README.md
Normal 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
12
astro.config.mjs
Normal 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
5069
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
public/brand/logo-mark.svg
Normal file
6
public/brand/logo-mark.svg
Normal 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
6
public/favicon.svg
Normal 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
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://jeanlucmakiola.de/sitemap-index.xml
|
||||||
10
src/components/Analytics.astro
Normal file
10
src/components/Analytics.astro
Normal 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>}
|
||||||
59
src/components/BaseHead.astro
Normal file
59
src/components/BaseHead.astro
Normal 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} />
|
||||||
23
src/components/Footer.astro
Normal file
23
src/components/Footer.astro
Normal 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>
|
||||||
16
src/components/FormattedDate.astro
Normal file
16
src/components/FormattedDate.astro
Normal 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>
|
||||||
30
src/components/Header.astro
Normal file
30
src/components/Header.astro
Normal 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
16
src/consts.ts
Normal 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
51
src/content.config.ts
Normal 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 };
|
||||||
14
src/content/blog/hello-world.md
Normal file
14
src/content/blog/hello-world.md
Normal 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.
|
||||||
42
src/content/projects/agendula.md
Normal file
42
src/content/projects/agendula.md
Normal 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.
|
||||||
48
src/content/projects/calendula.md
Normal file
48
src/content/projects/calendula.md
Normal 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.**
|
||||||
37
src/content/projects/floret-kit.md
Normal file
37
src/content/projects/floret-kit.md
Normal 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.
|
||||||
38
src/layouts/BaseLayout.astro
Normal file
38
src/layouts/BaseLayout.astro
Normal 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>
|
||||||
35
src/layouts/BlogPost.astro
Normal file
35
src/layouts/BlogPost.astro
Normal 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>
|
||||||
20
src/pages/blog/[...slug].astro
Normal file
20
src/pages/blog/[...slug].astro
Normal 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>
|
||||||
27
src/pages/blog/index.astro
Normal file
27
src/pages/blog/index.astro
Normal 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
110
src/pages/index.astro
Normal 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 & 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
21
src/pages/rss.xml.js
Normal 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}/`,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
41
src/pages/tags/[tag].astro
Normal file
41
src/pages/tags/[tag].astro
Normal 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
134
src/pages/work/[slug].astro
Normal 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>
|
||||||
39
src/pages/work/index.astro
Normal file
39
src/pages/work/index.astro
Normal 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
593
src/styles/global.css
Normal 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
226
src/styles/tokens.css
Normal 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
5
tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user