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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user