init elas atelier

This commit is contained in:
2026-05-14 08:24:41 +02:00
parent 3b704a24a7
commit 38f33cacb1
19 changed files with 1436 additions and 657 deletions
+25 -15
View File
@@ -1,6 +1,6 @@
# Narlblog
# Ela's Atelier
A single-author blog. Rust/Axum API backed by markdown files on disk; Astro/React frontend with a glass-effect Catppuccin theme. KaTeX math, GFM tables, server-side syntax highlighting, draft posts, RSS feed.
A single-curator art portfolio. Rust/Axum API backed by markdown files on disk; Astro/React frontend with a parchment "Salon Hang" aesthetic. Each entry is a markdown post that must contain at least one image; the first image becomes the cover plate in the catalogue.
```
backend/ Rust + Axum API (filesystem-backed)
@@ -29,7 +29,7 @@ sudo chown -R 1000:1000 data/
## Environment
| Variable | Required | Default | Notes |
| ----------------- | -------- | ----------------- | --------------------------------------------------------------------------- |
| ----------------- | -------- | --------------------- | --------------------------------------------------------------------------- |
| `ADMIN_TOKEN` | yes | — | Long random string. Stored as an HttpOnly cookie after login. |
| `PORT` | no | `3000` | Backend port. |
| `DATA_DIR` | no | `/app/data` | Where posts/uploads/config live. |
@@ -58,27 +58,31 @@ npm run dev
For a fully local stack, set `PUBLIC_API_URL=http://localhost:3000` in `frontend/.env`.
## Authoring posts
## Authoring a work
Posts are markdown files at `data/posts/<slug>.md` with YAML frontmatter:
Each work is a markdown file at `data/posts/<slug>.md` with YAML frontmatter. Every work **must** contain at least one markdown image — the first image becomes the cover plate on the gallery index. Image alt text becomes the figure caption on the work page.
```markdown
---
date: 2026-05-09
summary: Optional summary; falls back to auto-extracted excerpt.
summary: Optional short caption shown beneath the plate on the index.
tags:
- rust
- astro
- oil
- 2026
draft: false
---
# My post
# Untitled (charcoal on paper)
Body in **markdown**, with $LaTeX$ via `$…$` / `$$…$$`, GFM tables, and fenced code blocks (```rust, ```ts, …).
![A view of the cliff at dawn](/uploads/cliff-dawn.jpg "Plate I — graphite, A3")
Notes on the piece: materials, references, what worked, what didn't.
![Detail of the foreground](/uploads/cliff-detail.jpg "Plate II — detail")
```
Posts with `draft: true` are hidden from the public list and 404 when accessed by anyone without an admin session. Posts are sorted by `date` descending on the frontpage.
The web editor at `/admin/editor` writes the same format and updates atomically (write to `.tmp`, rename over target).
- `draft: true` hides a work from the public catalogue and 404s for non-curators.
- Works are sorted by `date` descending on the index.
- The web editor at `/admin/editor` writes the same format and updates atomically.
## Uploads
@@ -86,12 +90,18 @@ Allowlisted extensions: jpg, jpeg, png, webp, gif, avif, pdf, txt, md, mp3, wav,
Max upload size: 50 MB.
## Theme
The default theme is **Salon** — aged parchment, oxblood ink, Fraunces/EB Garamond/Caveat typography. A **Salon Noir** variant (black gallery wall) is available via the theme switcher in the header.
Influences: Friedrich, Goya, Kahlo, Tillmans, Basquiat, Sherman, Matisse, Dix, Abramović.
## Backups
The deployed `data/` directory is the entire blog. Back it up with whatever you trust — `rsync`, restic, borg, a sidecar container; nothing fancy is built in.
The deployed `data/` directory is the entire gallery. Back it up with whatever you trust — `rsync`, restic, borg, a sidecar container; nothing fancy is built in.
```sh
rsync -av data/ backup-host:/path/to/narlblog-data/
rsync -av data/ backup-host:/path/to/gallery-data/
```
## Stack
+71 -1
View File
@@ -11,7 +11,7 @@ use crate::{
AppState,
auth::is_authed,
error::AppError,
models::{CreatePostRequest, PostDetail, PostInfo, PostMeta},
models::{CoverImage, CreatePostRequest, PostDetail, PostInfo, PostMeta},
};
const WORDS_PER_MINUTE: u32 = 200;
@@ -114,6 +114,59 @@ fn reading_time(body: &str) -> u32 {
(words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE.max(1)
}
/// Scan markdown for `![alt](url)` images. Returns (alt, url) pairs in order.
/// Skips inside fenced code blocks. Tolerates titles like `![alt](url "title")`.
fn extract_images(body: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut in_fence = false;
for line in body.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
let bytes = line.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'!' && bytes[i + 1] == b'[' {
if let Some(rel_close) = line[i + 2..].find(']') {
let close = i + 2 + rel_close;
if close + 1 < line.len() && bytes[close + 1] == b'(' {
if let Some(rel_paren) = line[close + 2..].find(')') {
let paren_end = close + 2 + rel_paren;
let alt = line[i + 2..close].to_string();
let url_field = line[close + 2..paren_end].trim();
let url = url_field
.split_once(|c: char| c.is_whitespace())
.map(|(u, _)| u)
.unwrap_or(url_field)
.trim_matches(|c| c == '<' || c == '>')
.to_string();
if !url.is_empty() {
out.push((alt, url));
}
i = paren_end + 1;
continue;
}
}
}
}
i += 1;
}
}
out
}
fn cover_from(images: &[(String, String)]) -> Option<CoverImage> {
images.first().map(|(alt, url)| CoverImage {
url: url.clone(),
alt: alt.clone(),
})
}
fn excerpt_from(meta: &PostMeta, body: &str) -> String {
if let Some(s) = meta.summary.as_ref() {
if !s.trim().is_empty() {
@@ -191,6 +244,13 @@ pub async fn create_post(
}
}
let images = extract_images(&payload.content);
if images.is_empty() {
return Err(AppError::BadRequest(
"A gallery entry must include at least one image (![](url) in the markdown body).".to_string(),
));
}
let meta = PostMeta {
date: payload.date.unwrap_or_else(|| Utc::now().date_naive()),
title: payload.title.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()),
@@ -202,6 +262,8 @@ pub async fn create_post(
write_post_atomic(&state, &slug, &contents).await?;
info!("Post saved: {}", slug);
let image_count = images.len() as u32;
let cover = cover_from(&images);
Ok(Json(PostDetail {
slug,
date: meta.date,
@@ -211,6 +273,8 @@ pub async fn create_post(
draft: meta.draft,
reading_time: reading_time(&payload.content),
content: payload.content,
cover_image: cover,
image_count,
}))
}
@@ -271,6 +335,7 @@ pub async fn list_posts(
if meta.draft && !admin {
continue;
}
let images = extract_images(&body);
posts.push(PostInfo {
slug: slug.to_string(),
date: meta.date,
@@ -280,6 +345,8 @@ pub async fn list_posts(
draft: meta.draft,
reading_time: reading_time(&body),
excerpt: excerpt_from(&meta, &body),
cover_image: cover_from(&images),
image_count: images.len() as u32,
});
}
}
@@ -305,6 +372,7 @@ pub async fn get_post(
return Err(AppError::NotFound("Post not found".to_string()));
}
let images = extract_images(&body);
Ok(Json(PostDetail {
slug,
date: meta.date,
@@ -314,5 +382,7 @@ pub async fn get_post(
draft: meta.draft,
reading_time: reading_time(&body),
content: body,
cover_image: cover_from(&images),
image_count: images.len() as u32,
}))
}
+19 -6
View File
@@ -16,14 +16,15 @@ pub struct SiteConfig {
impl Default for SiteConfig {
fn default() -> Self {
Self {
title: "Narlblog".to_string(),
subtitle: "A clean, modern blog".to_string(),
welcome_title: "Welcome to my blog".to_string(),
title: "Ela's Atelier".to_string(),
subtitle: "Works on paper, canvas, and elsewhere".to_string(),
welcome_title: "Works on view".to_string(),
welcome_subtitle:
"Thoughts on software, design, and building things with Rust and Astro.".to_string(),
footer: "Built with Rust & Astro".to_string(),
"An ongoing arrangement of pieces, sketches, and stray observations."
.to_string(),
footer: "Hand-arranged with care".to_string(),
favicon: "/favicon.svg".to_string(),
theme: "mocha".to_string(),
theme: "salon".to_string(),
custom_css: "".to_string(),
}
}
@@ -62,6 +63,12 @@ pub struct PostMeta {
pub draft: bool,
}
#[derive(Serialize, Clone)]
pub struct CoverImage {
pub url: String,
pub alt: String,
}
#[derive(Serialize)]
pub struct PostInfo {
pub slug: String,
@@ -74,6 +81,9 @@ pub struct PostInfo {
pub draft: bool,
pub reading_time: u32,
pub excerpt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_image: Option<CoverImage>,
pub image_count: u32,
}
#[derive(Serialize)]
@@ -88,6 +98,9 @@ pub struct PostDetail {
pub draft: bool,
pub reading_time: u32,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_image: Option<CoverImage>,
pub image_count: u32,
}
#[derive(Deserialize)]
+6 -18
View File
@@ -1,26 +1,14 @@
---
date: 2026-05-09
summary: Markdown smoke test — bold, italic, links, blockquotes, fenced code.
summary: A second placeholder — layout smoke test.
tags:
- meta
- intro
draft: false
---
# My Second Blog Post
# Second placeholder
Adding some more content to test the layout and the glassy look!
A second placeholder so the salon-hang layout has room to breathe with more than one plate. Remove or replace from `/admin`.
### Markdown Testing
![Placeholder plate](/uploads/placeholder.jpg "replace me")
- **Bold text**
- *Italic text*
- [A link to GitHub](https://github.com)
> "The only way to do great work is to love what you do." - Steve Jobs
```rust
fn main() {
println!("Hello from Rust!");
}
```
Enjoy reading!
> "The painter constructs, the photographer discloses." — Susan Sontag
+5 -12
View File
@@ -1,21 +1,14 @@
---
date: 2026-05-09
summary: First post — modern stack, glassy aesthetic, Catppuccin theme.
summary: Opening note for the gallery — what's on the walls, why these pieces.
tags:
- meta
- intro
draft: false
---
# Welcome to Narlblog
# Welcome to the gallery
This is my very first blog post! Built with a modern, glassy aesthetic and the beautiful Catppuccin color palette.
This room collects work made on paper, canvas, and elsewhere — finished pieces alongside the studies that didn't make it.
## Technical Stack
![Placeholder plate](/uploads/placeholder.jpg "replace with a real plate from /admin/assets")
The blog is powered by:
- **Rust (Axum)**: Fast and reliable backend.
- **Astro**: Modern frontend framework for performance.
- **Tailwind CSS**: Glassy UI with Catppuccin theme.
- **Docker**: Simple containerized deployment.
Feel free to explore and add your own posts by creating `.md` files in the `data/posts` directory!
Replace this entry with your first real work, or remove it from the catalogue via the admin dashboard.
+30
View File
@@ -17,8 +17,11 @@
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.40.0",
"@fontsource-variable/eb-garamond": "^5.2.7",
"@fontsource-variable/fraunces": "^5.2.9",
"@fontsource-variable/inter": "^5.2.5",
"@fontsource-variable/jetbrains-mono": "^5.2.5",
"@fontsource/caveat": "^5.2.8",
"@replit/codemirror-vim": "^6.3.0",
"@tailwindcss/vite": "^4.2.2",
"astro": "^6.0.8",
@@ -1684,6 +1687,24 @@
}
}
},
"node_modules/@fontsource-variable/eb-garamond": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource-variable/eb-garamond/-/eb-garamond-5.2.7.tgz",
"integrity": "sha512-TbR+Pun4k5g85lgdxRNSbWka5vJgi9pzdFzFswgf7/7NRhKssvoumft1+ZGz3n91YYet96JcnjWrgPGGj6JTzg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/fraunces": {
"version": "5.2.9",
"resolved": "https://registry.npmjs.org/@fontsource-variable/fraunces/-/fraunces-5.2.9.tgz",
"integrity": "sha512-Y6IjunlN9Ni723np+GIgAaKzCDBrPRrqNi01TZxHs5wtHYROWFM9W6yiT+/gGwSjWIRD18oX17kD/BRWekc/Lw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
@@ -1702,6 +1723,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/caveat": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.2.8.tgz",
"integrity": "sha512-9fUUfFE2IQFKbx+xOcaeQxxmh8iJguEb8z+j1PeueO4UUx+XfT4pRm/B04ZDvFA794/iRxY/IibmP8ZKtIf4rw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+3
View File
@@ -21,8 +21,11 @@
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.40.0",
"@fontsource-variable/eb-garamond": "^5.2.7",
"@fontsource-variable/fraunces": "^5.2.9",
"@fontsource-variable/inter": "^5.2.5",
"@fontsource-variable/jetbrains-mono": "^5.2.5",
"@fontsource/caveat": "^5.2.8",
"@replit/codemirror-vim": "^6.3.0",
"@tailwindcss/vite": "^4.2.2",
"astro": "^6.0.8",
+119 -95
View File
@@ -1,14 +1,22 @@
import { useState } from 'react';
import { deletePost } from '../../lib/api';
interface CoverImage {
url: string;
alt: string;
}
interface Post {
slug: string;
date: string;
title?: string;
excerpt?: string;
summary?: string;
tags: string[];
draft: boolean;
reading_time: number;
cover_image?: CoverImage;
image_count: number;
}
interface Props {
@@ -24,101 +32,144 @@ function formatSlug(slug: string) {
.join(' ');
}
function formatDate(date: string) {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
function formatYear(date: string) {
return new Date(date).getFullYear();
}
function formatMonth(date: string) {
return new Date(date).toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
}
function toRoman(n: number): string {
const map: [number, string][] = [
[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'],
];
let out = '';
for (const [val, sym] of map) {
while (n >= val) { out += sym; n -= val; }
}
return out;
}
// Deterministic salon-hang layout. Each tile gets a col-span (out of 12) and an aspect ratio.
// The cycle is chosen so the room reads asymmetric but balanced.
const LAYOUT_CYCLE: Array<{ col: number; aspect: string; tilt: number }> = [
{ col: 7, aspect: '4 / 3', tilt: -0.4 },
{ col: 5, aspect: '3 / 4', tilt: 0.3 },
{ col: 4, aspect: '4 / 5', tilt: -0.2 },
{ col: 4, aspect: '1 / 1', tilt: 0.5 },
{ col: 4, aspect: '4 / 5', tilt: -0.6 },
{ col: 5, aspect: '1 / 1', tilt: 0.2 },
{ col: 7, aspect: '5 / 4', tilt: -0.3 },
{ col: 8, aspect: '16 / 10', tilt: 0.4 },
{ col: 4, aspect: '3 / 4', tilt: -0.5 },
];
export default function PostList({ posts: initialPosts, isAdmin = false }: Props) {
const [posts, setPosts] = useState(initialPosts);
const [deleting, setDeleting] = useState<string | null>(null);
async function handleDelete(slug: string, title: string) {
if (deleting) return;
if (!window.confirm(`Delete "${title}"? This cannot be undone.`)) return;
if (!window.confirm(`Take "${title}" off the wall? This cannot be undone.`)) return;
setDeleting(slug);
try {
await deletePost(slug);
setPosts(p => p.filter(x => x.slug !== slug));
} catch (e) {
window.alert(`Failed to delete: ${e instanceof Error ? e.message : 'unknown error'}`);
window.alert(`Failed to remove: ${e instanceof Error ? e.message : 'unknown error'}`);
} finally {
setDeleting(null);
}
}
if (posts.length === 0) {
return (
<div className="glass p-8 md:p-12 text-center text-sm md:text-base text-subtext1">
No posts yet.
</div>
);
return null;
}
return (
<div className="flex flex-col space-y-6">
{posts.map(post => {
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-x-10 md:gap-y-16">
{posts.map((post, idx) => {
const displayTitle = post.title || formatSlug(post.slug);
const isDeleting = deleting === post.slug;
const layout = LAYOUT_CYCLE[idx % LAYOUT_CYCLE.length];
const exhibitNumber = toRoman(idx + 1);
const hasCover = !!post.cover_image?.url;
return (
<article
key={post.slug}
className={`glass p-5 md:p-8 transition-all hover:bg-surface0/80 group relative ${
isDeleting ? 'opacity-50 pointer-events-none' : ''
}`}
className={`relative plate-enter md-col-span ${isDeleting ? 'opacity-40 pointer-events-none' : ''}`}
style={{
animationDelay: `${Math.min(idx * 80, 480)}ms`,
['--col-span' as any]: layout.col,
}}
>
<a href={`/posts/${encodeURIComponent(post.slug)}`} className="block">
<div className="flex flex-col md:flex-row md:items-center gap-4 md:gap-6">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mb-2 text-xs text-subtext0">
<time dateTime={post.date}>{formatDate(post.date)}</time>
<span className="opacity-50">·</span>
<span>{post.reading_time} min read</span>
{post.draft && (
<>
<span className="opacity-50">·</span>
<span className="text-peach uppercase tracking-wide font-semibold">
Draft
<a
href={`/posts/${encodeURIComponent(post.slug)}`}
className="block plate group"
style={{ transform: `rotate(${layout.tilt}deg)` }}
aria-label={`View ${displayTitle}`}
>
<span className="plate-tag"> {exhibitNumber}</span>
<div
className="plate-image"
style={{ aspectRatio: layout.aspect }}
>
{hasCover ? (
<img
src={post.cover_image!.url}
alt={post.cover_image!.alt || displayTitle}
loading={idx < 3 ? 'eager' : 'lazy'}
/>
) : (
<div
className="w-full h-full flex items-center justify-center text-[var(--rosewater)]"
style={{
background: `linear-gradient(135deg, var(--mauve), var(--mantle))`,
}}
>
<span className="font-display italic text-3xl opacity-70">
untitled
</span>
</div>
)}
{post.image_count > 1 && (
<span className="plate-tag-mini">
{post.image_count} plates
</span>
)}
{post.draft && (
<span className="plate-tag-mini" style={{ left: 18, right: 'auto', background: 'var(--peach)', color: 'var(--crust)' }}>
Sketch
</span>
</>
)}
</div>
<h2 className="text-xl md:text-2xl font-semibold text-lavender group-hover:text-mauve transition-colors mb-2 pr-20">
{displayTitle}
</h2>
<p className="text-subtext1 text-sm md:text-base leading-relaxed line-clamp-2">
{post.excerpt || `Read more about ${displayTitle}...`}
</p>
<div className="plate-caption">
<div className="min-w-0">
<div className="plate-caption-title truncate">{displayTitle}</div>
{post.summary && (
<div className="mt-1 text-xs text-[var(--subtext0)] font-sans italic line-clamp-1">
{post.summary}
</div>
<div className="text-mauve opacity-0 group-hover:opacity-100 transition-opacity self-end md:self-auto shrink-0 hidden md:block">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
)}
</div>
<div className="plate-caption-meta">
<span>{formatMonth(post.date)}</span>
<span className="opacity-50 mx-1">·</span>
<span>{formatYear(post.date)}</span>
</div>
</div>
</a>
{post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{post.tags.map(tag => (
<span
key={tag}
className="text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full bg-surface0 text-subtext0 border border-surface1"
>
<div className="flex flex-wrap gap-1.5 mt-3 px-1">
{post.tags.slice(0, 4).map(tag => (
<span key={tag} className="chip">
{tag}
</span>
))}
@@ -126,54 +177,27 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
)}
{isAdmin && (
<div className="absolute top-4 right-4 md:top-5 md:right-5 flex items-center gap-1.5">
<div className="absolute top-3 right-3 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<a
href={`/admin/editor?edit=${encodeURIComponent(post.slug)}`}
onClick={e => e.stopPropagation()}
title="Edit post"
title="Edit"
aria-label={`Edit ${displayTitle}`}
className="p-1.5 rounded-md bg-surface0/80 hover:bg-blue/20 text-subtext0 hover:text-blue border border-surface1 transition-colors"
className="p-1.5 bg-[var(--mantle)] text-[var(--rosewater)] hover:bg-[var(--blue)] border border-[var(--surface2)] transition-colors"
style={{ borderRadius: 1 }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /><path d="m15 5 4 4" /></svg>
</a>
<button
type="button"
onClick={e => { e.preventDefault(); e.stopPropagation(); handleDelete(post.slug, displayTitle); }}
disabled={isDeleting}
title="Delete post"
aria-label={`Delete ${displayTitle}`}
className="p-1.5 rounded-md bg-surface0/80 hover:bg-red/20 text-subtext0 hover:text-red border border-surface1 transition-colors disabled:opacity-50"
title="Remove"
aria-label={`Remove ${displayTitle}`}
className="p-1.5 bg-[var(--mantle)] text-[var(--rosewater)] hover:bg-[var(--red)] border border-[var(--surface2)] transition-colors disabled:opacity-50"
style={{ borderRadius: 1 }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><line x1="10" x2="10" y1="11" y2="17" /><line x1="14" x2="14" y1="11" y2="17" /></svg>
</button>
</div>
)}
@@ -1,11 +1,10 @@
import { useState, useEffect, useRef } from 'react';
const THEMES = [
{ value: 'mocha', label: 'Mocha' },
{ value: 'macchiato', label: 'Macchiato' },
{ value: 'frappe', label: 'Frappe' },
{ value: 'salon', label: 'Salon' },
{ value: 'salon-noir', label: 'Salon Noir' },
{ value: 'latte', label: 'Latte' },
{ value: 'scaled-and-icy', label: 'Scaled and Icy' },
{ value: 'mocha', label: 'Mocha' },
];
interface Props {
@@ -44,13 +43,14 @@ export default function ThemeSwitcher({ defaultTheme = 'mocha' }: Props) {
value={theme}
onChange={(e) => setTheme(e.target.value)}
aria-label="Theme"
className="appearance-none bg-surface0/50 text-text border border-surface1 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-mauve transition-colors cursor-pointer hover:bg-surface0 pr-8 shadow-sm"
className="appearance-none bg-[var(--surface0)]/60 text-[var(--text)] border border-[var(--surface2)] px-3 py-1.5 text-xs uppercase tracking-[0.18em] focus:outline-none focus:border-[var(--mauve)] transition-colors cursor-pointer hover:bg-[var(--surface0)] pr-8 font-display italic"
style={{ borderRadius: 1 }}
>
{THEMES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-subtext0">
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-[var(--subtext0)]">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
{toast && <div className="toast" role="status">{toast}</div>}
+16 -12
View File
@@ -16,7 +16,7 @@ interface Props {
editSlug?: string;
}
const narlblogTheme = EditorView.theme({
const salonTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--crust)',
color: 'var(--text)',
@@ -121,8 +121,8 @@ export default function Editor({ editSlug }: Props) {
closeBrackets(),
markdown({ base: markdownLanguage, codeLanguages: languages }),
EditorView.lineWrapping,
narlblogTheme,
cmPlaceholder('# Hello World\nWrite your markdown here...'),
salonTheme,
cmPlaceholder('# A title for the work\n\n![alt text](/uploads/your-image.jpg "optional caption")\n\nNotes, context, materials...'),
EditorView.updateListener.of(update => {
if (!update.docChanged) return;
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
@@ -229,7 +229,11 @@ export default function Editor({ editSlug }: Props) {
async function handleSave() {
const content = viewRef.current?.state.doc.toString() || '';
if (!title.trim() || !slug || !content) {
showAlertMsg('Title, slug, and content are required.', 'error');
showAlertMsg('Title, slug, and body are required.', 'error');
return;
}
if (!/!\[[^\]]*\]\([^)]+\)/.test(content)) {
showAlertMsg('A gallery work must include at least one image — insert via the Assets panel or type "!".', 'error');
return;
}
const tags = tagsInput
@@ -260,7 +264,7 @@ export default function Editor({ editSlug }: Props) {
async function handleDelete() {
const target = originalSlug || slug;
if (!confirm(`Delete post "${target}" permanently?`)) return;
if (!confirm(`Remove work "${target}" from the catalogue permanently?`)) return;
try {
await deletePost(target);
window.location.href = '/admin';
@@ -293,8 +297,8 @@ export default function Editor({ editSlug }: Props) {
Delete
</button>
)}
<button onClick={handleSave} className="bg-mauve text-crust font-bold py-3 px-8 rounded-lg hover:bg-pink transition-all transform hover:scale-105 whitespace-nowrap">
Save Post
<button onClick={handleSave} className="bg-mauve text-rosewater font-bold py-3 px-8 rounded-lg hover:bg-red transition-all transform hover:scale-105 whitespace-nowrap">
Save work
</button>
{originalSlug && (
<a
@@ -304,7 +308,7 @@ export default function Editor({ editSlug }: Props) {
className="bg-blue text-crust font-bold py-3 px-8 rounded-lg hover:bg-sky transition-all transform hover:scale-105 whitespace-nowrap inline-flex items-center justify-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
View Post
View work
</a>
)}
</div>
@@ -319,7 +323,7 @@ export default function Editor({ editSlug }: Props) {
value={title}
onChange={e => setTitle(e.target.value)}
required
placeholder="My Awesome Post"
placeholder="Untitled (charcoal on paper)"
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
/>
</div>
@@ -357,7 +361,7 @@ export default function Editor({ editSlug }: Props) {
type="text"
value={tagsInput}
onChange={e => setTagsInput(e.target.value)}
placeholder="rust, astro, design"
placeholder="oil, paper, 2026, study"
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
/>
</div>
@@ -379,7 +383,7 @@ export default function Editor({ editSlug }: Props) {
value={summary}
onChange={e => setSummary(e.target.value)}
rows={2}
placeholder="A brief description of this post for the frontpage..."
placeholder="A short caption for the catalogue index..."
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors resize-none"
/>
</div>
@@ -387,7 +391,7 @@ export default function Editor({ editSlug }: Props) {
{/* Editor Toolbar */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-2">
<div className="flex items-center gap-3">
<label className="block text-sm font-medium text-subtext1 italic">Tip: Type '/' to browse assets</label>
<label className="block text-sm font-medium text-subtext1 italic">Type '/' or '!' to insert an image · at least one image is required</label>
</div>
<div className="flex items-center gap-2">
<button
+21 -11
View File
@@ -20,22 +20,32 @@ export default function Login() {
window.location.href = '/admin';
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
setError('Invalid token.');
setError('That key does not open this door.');
} else {
setError('Login failed. Try again.');
setError('Could not reach the door. Try again.');
}
setBusy(false);
}
}
return (
<div className="max-w-md mx-auto mt-20">
<div className="glass p-12">
<h1 className="text-3xl font-bold mb-6 text-mauve">Admin Login</h1>
<p className="text-subtext0 mb-8">Enter your admin token to access the dashboard.</p>
<div className="max-w-md mx-auto mt-16">
<div className="glass p-10">
<div className="font-display italic text-[var(--subtext0)] text-xs tracking-[0.3em] uppercase mb-3 text-center">
Curator's entrance
</div>
<h1 className="font-display italic text-3xl md:text-4xl font-semibold text-[var(--text)] mb-2 text-center leading-tight">
Sign in
</h1>
<div className="section-rule max-w-[180px] mx-auto mb-6">
<span className="ornament">✦</span>
</div>
<p className="text-[var(--subtext1)] mb-8 text-center font-display italic">
Present your token to enter the back room.
</p>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="token" className="block text-sm font-medium text-subtext1 mb-2">Admin Token</label>
<label htmlFor="token" className="field-label">Admin token</label>
<input
type="password"
id="token"
@@ -43,21 +53,21 @@ export default function Login() {
value={value}
onChange={e => setValue(e.target.value)}
autoComplete="current-password"
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
className="field-input font-mono tracking-widest"
placeholder="••••••••••••"
/>
</div>
{error && (
<p className="text-sm text-red bg-red/10 border border-red/20 rounded-lg px-3 py-2">
<p className="text-sm text-[var(--red)] bg-[var(--red)]/10 border border-[var(--red)]/30 px-3 py-2 font-display italic">
{error}
</p>
)}
<button
type="submit"
disabled={busy}
className="w-full bg-mauve text-crust font-bold py-3 rounded-lg hover:bg-pink transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className="btn-stamp w-full justify-center disabled:opacity-60 disabled:cursor-not-allowed"
>
{busy ? 'Logging in...' : 'Login'}
{busy ? 'Unlocking' : 'Enter'}
</button>
</form>
</div>
+7 -6
View File
@@ -14,14 +14,15 @@ if (Astro.cookies.get('admin_session')?.value !== '1') {
---
<Layout title={title} wide={wide}>
<div class="space-y-6 md:space-y-10">
<header class="flex flex-col md:flex-row md:items-end justify-between gap-4 pb-6 border-b border-surface1/40">
<div class="space-y-10 md:space-y-14">
<header class="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-[var(--surface2)]/60">
<div class="flex-1 min-w-0">
<a href="/" class="inline-flex items-center gap-1.5 text-xs text-subtext0 hover:text-text transition-colors mb-2 group">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:-translate-x-0.5" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
Back to site
<a href="/" class="inline-flex items-center gap-1.5 text-xs font-display italic text-[var(--subtext0)] hover:text-[var(--mauve)] transition-colors mb-3 group">
<span class="transition-transform group-hover:-translate-x-0.5">←</span>
Back to the catalogue
</a>
<h1 class="text-2xl md:text-4xl font-bold text-mauve tracking-tight">
<div class="font-display italic text-[var(--mauve)] text-xs tracking-[0.3em] uppercase mb-2">Curator's desk</div>
<h1 class="font-display italic font-semibold text-[var(--text)] text-4xl md:text-5xl tracking-tight leading-[1.05]">
{title}
</h1>
<slot name="header-subtitle" />
+44 -40
View File
@@ -2,7 +2,9 @@
import '../styles/global.css';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/atom-one-dark.css';
import '@fontsource-variable/inter';
import '@fontsource-variable/fraunces';
import '@fontsource-variable/eb-garamond';
import '@fontsource/caveat';
import '@fontsource-variable/jetbrains-mono';
import ThemeSwitcher from '../components/react/ThemeSwitcher';
import Search from '../components/react/Search';
@@ -23,11 +25,11 @@ const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
let siteConfig = {
title: "Narlblog",
subtitle: "A clean, modern blog",
footer: "Built with Rust & Astro",
title: "Ela's Atelier",
subtitle: "Works on paper, canvas, and elsewhere",
footer: "Hand-arranged with care",
favicon: "/favicon.svg",
theme: "mocha",
theme: "salon",
custom_css: ""
};
@@ -40,7 +42,8 @@ try {
console.error("Failed to fetch config:", e);
}
const fullTitle = `${title} | ${siteConfig.title}`;
const fullTitle = `${title} · ${siteConfig.title}`;
const year = new Date().getFullYear();
---
<!doctype html>
@@ -65,22 +68,17 @@ const fullTitle = `${title} | ${siteConfig.title}`;
<link rel="alternate" type="application/rss+xml" title={siteConfig.title} href="/feed.xml" />
{siteConfig.custom_css && <style set:html={siteConfig.custom_css} />}
<script is:inline define:vars={{ defaultTheme: siteConfig.theme }}>
const savedTheme = localStorage.getItem('user-theme') || defaultTheme;
const savedTheme = localStorage.getItem('user-theme') || defaultTheme || 'salon';
document.documentElement.classList.add(savedTheme);
</script>
<slot name="head" />
</head>
<body class="bg-bg text-text selection:bg-surface2 selection:text-text">
<!-- Static Mesh Gradient Background -->
<div class="fixed inset-0 z-[-1] overflow-hidden bg-bg pointer-events-none">
<div class="absolute top-[-10%] left-[-10%] w-[55%] h-[45%] rounded-full bg-mauve/10 blur-[110px] opacity-60"></div>
<div class="absolute bottom-[-10%] right-[-10%] w-[55%] h-[55%] rounded-full bg-blue/10 blur-[110px] opacity-50"></div>
<div class="absolute top-[30%] right-[10%] w-[35%] h-[35%] rounded-full bg-teal/8 blur-[90px] opacity-40"></div>
</div>
<body class="text-text">
<div class="salon-atmosphere" aria-hidden="true"></div>
<nav class="max-w-6xl mx-auto px-4 md:px-6 py-4 md:py-8">
<header class="glass px-4 py-3 md:px-6 md:py-4 flex flex-col md:flex-row items-center justify-between gap-4">
<div class="w-full md:w-auto text-center md:text-left">
<header class="border-b border-[var(--surface2)]/60 relative z-10">
<div class="max-w-6xl mx-auto px-6 md:px-10 py-5 md:py-7 flex flex-col md:flex-row md:items-end gap-4 md:gap-6">
<a href="/" class="nameplate group" aria-label="Home">
{isAdmin ? (
<EditableText
client:load
@@ -88,54 +86,60 @@ const fullTitle = `${title} | ${siteConfig.title}`;
fieldKey="title"
isAdmin
ariaLabel="site title"
className="text-xl md:text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-mauve to-blue inline-block"
className="nameplate-title"
/>
) : (
<a href="/" class="text-xl md:text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-mauve to-blue block">
{siteConfig.title}
</a>
<span class="nameplate-title group-hover:text-[var(--mauve)] transition-colors">{siteConfig.title}</span>
)}
{isAdmin ? (
<div class="text-[8px] md:text-[10px] text-subtext0 uppercase tracking-widest">
<EditableText
client:load
initial={siteConfig.subtitle}
fieldKey="subtitle"
isAdmin
ariaLabel="site subtitle"
className="inline"
className="nameplate-subtitle"
/>
</div>
) : (
<p class="text-[8px] md:text-[10px] text-subtext0 uppercase tracking-widest">{siteConfig.subtitle}</p>
<span class="nameplate-subtitle">{siteConfig.subtitle}</span>
)}
</a>
<div class="flex-1 hidden md:flex items-end justify-center pb-1">
<span class="font-display italic text-[var(--subtext0)] text-sm tracking-wider">— exhibition est. {year} —</span>
</div>
<div class="flex gap-2 md:gap-3 items-center text-sm md:text-base w-full md:w-auto justify-center md:justify-end">
<div class="flex flex-wrap items-center gap-3 justify-start md:justify-end">
<Search client:load />
{isAdmin && (
<div class="flex items-center gap-1 pl-2 ml-1 border-l border-surface1/60">
<div class="flex items-center gap-1 pl-3 ml-1 border-l border-[var(--surface2)]/60">
<a
href="/"
class="inline-flex items-center gap-1.5 text-xs uppercase tracking-wider px-2.5 py-1 rounded-full bg-mauve/15 text-mauve border border-mauve/30 hover:bg-mauve/25 transition-colors"
title="Signed in as admin"
href="/admin"
class="chip chip-accent uppercase"
title="Signed in as curator"
>
<span class="w-1.5 h-1.5 rounded-full bg-mauve animate-pulse"></span>
Admin
<span class="w-1.5 h-1.5 rounded-full bg-[var(--rosewater)] animate-pulse"></span>
Curator
</a>
<LogoutButton client:load />
</div>
)}
<ThemeSwitcher client:only="react" defaultTheme={siteConfig.theme} />
</div>
</div>
</header>
</nav>
<main class={`mx-auto px-4 md:px-6 py-4 md:py-8 ${wide ? 'max-w-[95vw]' : 'max-w-6xl'}`}>
<main class={`mx-auto px-6 md:px-10 py-10 md:py-16 relative z-10 ${wide ? 'max-w-[95vw]' : 'max-w-6xl'}`}>
<slot />
</main>
<footer class="max-w-6xl mx-auto px-4 md:px-6 py-8 md:py-12 text-center text-xs md:text-sm text-subtext1 border-t border-white/5 mt-8 md:mt-12">
<p class="mb-2">
<footer class="max-w-6xl mx-auto px-6 md:px-10 py-12 md:py-16 text-center border-t border-[var(--surface2)]/40 mt-12 relative z-10">
<div class="section-rule mb-6">
<span class="ornament">✦</span>
<span class="font-display italic text-[var(--subtext0)] text-sm">end of catalogue</span>
<span class="ornament">✦</span>
</div>
<p class="font-display italic text-base text-[var(--subtext1)] mb-2">
{isAdmin ? (
<EditableText
client:load
@@ -147,11 +151,11 @@ const fullTitle = `${title} | ${siteConfig.title}`;
/>
) : siteConfig.footer}
</p>
<div class="text-xs text-subtext0 mb-2">
<a href="/feed.xml" class="hover:text-mauve transition-colors">RSS</a>
<div class="text-xs text-[var(--subtext0)] tracking-[0.2em] uppercase mb-3">
<a href="/feed.xml" class="hover:text-[var(--mauve)] transition-colors">RSS Feed</a>
</div>
<div class="text-subtext0 opacity-50">
&copy; {new Date().getFullYear()} {siteConfig.title}
<div class="text-[var(--overlay0)] text-xs italic">
&copy; {year} · {siteConfig.title}
</div>
</footer>
</body>
+27 -3
View File
@@ -5,6 +5,15 @@ import { gfmHeadingId } from 'marked-gfm-heading-id';
import hljs from 'highlight.js';
import DOMPurify from 'isomorphic-dompurify';
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
const renderer = new Marked()
.setOptions({ gfm: true, breaks: false })
.use(gfmHeadingId())
@@ -17,7 +26,22 @@ const renderer = new Marked()
},
}),
)
.use(markedKatex({ throwOnError: false, nonStandard: true }));
.use(markedKatex({ throwOnError: false, nonStandard: true }))
.use({
renderer: {
image({ href, title, text }: { href: string; title: string | null; text: string }) {
const safeHref = escapeHtml(href);
const safeAlt = escapeHtml(text || '');
const safeTitle = title ? escapeHtml(title) : '';
const caption = title || text || '';
const figcaption = caption.trim()
? `<figcaption>${escapeHtml(caption)}</figcaption>`
: '';
const titleAttr = safeTitle ? ` title="${safeTitle}"` : '';
return `<figure><img src="${safeHref}" alt="${safeAlt}"${titleAttr} loading="lazy" />${figcaption}</figure>`;
},
},
});
const KATEX_TAGS = [
'math', 'annotation', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'mtext',
@@ -29,7 +53,7 @@ const KATEX_TAGS = [
export function renderMarkdown(src: string): string {
const html = renderer.parse(src, { async: false }) as string;
return DOMPurify.sanitize(html, {
ADD_TAGS: KATEX_TAGS,
ADD_ATTR: ['aria-hidden', 'style', 'id', 'class', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel'],
ADD_TAGS: [...KATEX_TAGS, 'figure', 'figcaption'],
ADD_ATTR: ['aria-hidden', 'style', 'id', 'class', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel', 'loading'],
});
}
+17 -15
View File
@@ -2,21 +2,23 @@
import Layout from '../layouts/Layout.astro';
---
<Layout title="Not found" description="The page you're looking for doesn't exist.">
<div class="glass p-8 md:p-16 text-center max-w-2xl mx-auto mt-8 md:mt-16">
<p class="text-7xl md:text-8xl font-extrabold bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal mb-4">
404
<Layout title="Not in the catalogue" description="The work you're looking for is not on view.">
<div class="max-w-2xl mx-auto py-16 md:py-24 text-center">
<div class="numeral text-[var(--mauve)] text-[8rem] md:text-[12rem] leading-none mb-4">
CDIV
</div>
<div class="section-rule max-w-sm mx-auto mb-8">
<span class="ornament">✦</span>
<span>Pardon — the gallery has misplaced this work</span>
<span class="ornament">✦</span>
</div>
<h1 class="font-display italic text-3xl md:text-5xl text-[var(--text)] mb-6 leading-tight">
This piece is not on view.
</h1>
<p class="text-[var(--subtext1)] font-sans text-lg mb-10 leading-relaxed">
The room you reached for has either been re-hung, withdrawn,<br class="hidden md:block" />
or never made it to the wall in the first place.
</p>
<h1 class="text-2xl md:text-3xl font-semibold text-text mb-3">Page not found</h1>
<p class="text-subtext1 mb-8">
The page you're looking for has moved, been deleted, or never existed.
</p>
<a
href="/"
class="inline-flex items-center gap-2 bg-mauve text-crust font-semibold px-5 py-2.5 rounded-lg hover:bg-pink transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
Back to home
</a>
<a href="/" class="btn-stamp">↶ Return to the catalogue</a>
</div>
</Layout>
+1 -1
View File
@@ -41,7 +41,7 @@ export const GET: APIRoute = async ({ site }) => {
const origin = site?.toString().replace(/\/$/, '') || '';
let posts: PostInfo[] = [];
let config: SiteConfig = { title: 'Narlblog', subtitle: 'A clean, modern blog' };
let config: SiteConfig = { title: "Ela's Atelier", subtitle: 'Works on paper, canvas, and elsewhere' };
try {
const [pr, cr] = await Promise.all([
fetch(`${API_URL}/api/posts`),
+57 -43
View File
@@ -6,21 +6,29 @@ import AssetsButton from '../components/react/AssetsButton';
const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000';
interface CoverImage {
url: string;
alt: string;
}
interface Post {
slug: string;
date: string;
title?: string;
summary?: string;
excerpt?: string;
tags: string[];
draft: boolean;
reading_time: number;
cover_image?: CoverImage;
image_count: number;
}
let posts: Post[] = [];
let error = '';
let siteConfig = {
welcome_title: "Welcome to my blog",
welcome_subtitle: "Thoughts on software, design, and building things with Rust and Astro."
welcome_title: "Works on view",
welcome_subtitle: "An ongoing arrangement of pieces, sketches, and stray observations."
};
try {
@@ -32,7 +40,7 @@ try {
if (postsRes.ok) {
posts = await postsRes.json();
} else {
error = 'Failed to fetch posts';
error = 'Failed to fetch works';
}
if (configRes.ok) {
@@ -45,12 +53,17 @@ try {
}
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
const total = posts.length;
---
<Layout title="Home" description={siteConfig.welcome_subtitle}>
<div class="space-y-6 md:space-y-8">
<section class="text-center py-6 md:py-12">
<h1 class="text-3xl md:text-5xl font-extrabold mb-3 md:mb-4 pb-2 md:pb-4 leading-tight">
<Layout title="Catalogue" description={siteConfig.welcome_subtitle}>
<section class="relative mb-16 md:mb-24">
<div class="flex flex-col md:flex-row md:items-end gap-8 md:gap-12">
<div class="flex-1 max-w-2xl">
<div class="font-display italic text-[var(--subtext0)] text-sm tracking-[0.3em] uppercase mb-4">
Currently arranged — {new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
</div>
<h1 class="font-display italic font-semibold text-[var(--text)] text-5xl md:text-7xl lg:text-8xl leading-[0.95] tracking-tight mb-6">
{isAdmin ? (
<EditableText
client:load
@@ -58,15 +71,11 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
fieldKey="welcome_title"
isAdmin
ariaLabel="welcome title"
className="bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal"
className="inline"
/>
) : (
<span class="bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal">
{siteConfig.welcome_title}
</span>
)}
) : siteConfig.welcome_title}
</h1>
<p class="text-subtext1 text-base md:text-lg max-w-2xl mx-auto px-4 md:px-0">
<p class="font-sans text-lg md:text-xl text-[var(--subtext1)] leading-relaxed max-w-xl">
{isAdmin ? (
<EditableText
client:load
@@ -81,50 +90,55 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
</p>
{isAdmin && (
<div class="mt-6 md:mt-8 flex flex-wrap items-center justify-center gap-2 md:gap-3">
<a
href="/admin/editor"
class="inline-flex items-center gap-2 bg-mauve text-crust font-semibold px-4 py-2 rounded-lg hover:bg-pink transition-colors text-sm shadow-lg shadow-mauve/20"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
New Post
<div class="mt-8 flex flex-wrap items-center gap-3">
<a href="/admin/editor" class="btn-stamp">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
Hang new work
</a>
<AssetsButton client:load />
<a
href="/admin/settings"
class="inline-flex items-center gap-2 bg-surface0 hover:bg-surface1 text-subtext1 hover:text-text px-3 py-2 rounded-lg border border-surface1 transition-colors text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
<a href="/admin/settings" class="btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/></svg>
Settings
</a>
</div>
)}
</div>
<aside class="md:w-64 lg:w-80 shrink-0 md:pb-2">
<div class="font-display italic text-[var(--subtext0)] text-xs tracking-[0.3em] uppercase mb-2">Index</div>
<div class="numeral text-5xl md:text-6xl text-[var(--mauve)] leading-none mb-3">
{String(total).padStart(2, '0')}
</div>
<div class="font-display italic text-[var(--subtext1)] text-base leading-snug">
{total === 1 ? 'work hanging' : 'works hanging'},
<span class="font-hand text-[var(--mauve)] text-xl ml-1">arranged below</span>
</div>
<div class="mt-4 h-px bg-[var(--surface2)]"></div>
<div class="mt-3 text-[var(--subtext0)] font-display italic text-sm">
Scroll the room — no two visits the same.
</div>
</aside>
</div>
</section>
<div class="flex flex-col space-y-6">
{error && (
<div class="glass p-4 md:p-6 text-red text-center border-red/20 text-sm md:text-base">
{error}
<div class="glass p-6 md:p-8 text-center mb-12 border-[var(--red)]/40">
<p class="font-display italic text-[var(--red)] text-lg">{error}</p>
</div>
)}
{posts.length === 0 && !error && !isAdmin && (
<div class="glass p-8 md:p-12 text-center text-sm md:text-base text-subtext1">
<p>No posts yet — check back soon.</p>
</div>
{posts.length === 0 && !error && (
<div class="glass p-12 md:p-20 text-center max-w-2xl mx-auto">
<div class="font-display italic text-[var(--subtext0)] text-sm tracking-[0.3em] uppercase mb-4">Notice</div>
<p class="font-display italic text-[var(--text)] text-2xl md:text-3xl leading-snug mb-2">
The exhibition is currently being arranged.
</p>
<p class="font-sans text-[var(--subtext1)] mt-4">Please return shortly.</p>
{isAdmin && (
<a href="/admin/editor" class="btn-stamp mt-8">Hang the first work</a>
)}
{posts.length === 0 && !error && isAdmin && (
<div class="glass p-8 md:p-12 text-center text-sm md:text-base">
<p class="text-subtext1 mb-4">No posts yet. Write your first one.</p>
<a href="/admin/editor" class="inline-flex items-center gap-2 bg-mauve text-crust font-semibold px-4 py-2 rounded-lg hover:bg-pink transition-colors text-sm">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
New Post
</a>
</div>
)}
{posts.length > 0 && <PostList posts={posts} isAdmin={isAdmin} client:load />}
</div>
</div>
</Layout>
+136 -72
View File
@@ -6,6 +6,7 @@ import { renderMarkdown } from '../../lib/markdown';
const { slug } = Astro.params;
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
interface CoverImage { url: string; alt: string }
interface PostDetail {
slug: string;
date: string;
@@ -15,28 +16,29 @@ interface PostDetail {
tags: string[];
draft: boolean;
reading_time: number;
cover_image?: CoverImage;
image_count: number;
}
interface PostInfo {
slug: string;
title?: string;
}
function formatDate(d: string) {
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
}
let post: PostDetail | null = null;
let html = '';
let error = '';
try {
const response = await fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`);
if (response.ok) {
post = await response.json();
html = renderMarkdown(post!.content);
} else {
error = 'Post not found';
function toRoman(n: number): string {
const map: [number, string][] = [
[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'],
];
let out = '';
for (const [val, sym] of map) {
while (n >= val) { out += sym; n -= val; }
}
} catch (e) {
const cause = (e as any)?.cause;
error = `Could not connect to backend at ${API_URL}: ${e instanceof Error ? e.message : String(e)}${cause ? ' (Cause: ' + (cause.message || cause.code || JSON.stringify(cause)) + ')' : ''}`;
console.error(error);
return out;
}
function formatSlug(s: string) {
@@ -44,52 +46,74 @@ function formatSlug(s: string) {
return s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
let post: PostDetail | null = null;
let html = '';
let error = '';
let neighbors: { prev?: PostInfo; next?: PostInfo; index: number; total: number } = { index: -1, total: 0 };
try {
const [postRes, listRes] = await Promise.all([
fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`),
fetch(`${API_URL}/api/posts`),
]);
if (postRes.ok) {
post = await postRes.json();
html = renderMarkdown(post!.content);
} else {
error = 'Work not found in the catalogue';
}
if (listRes.ok) {
const list: PostInfo[] = await listRes.json();
const i = list.findIndex(p => p.slug === slug);
if (i >= 0) {
neighbors = {
index: i,
total: list.length,
prev: i > 0 ? list[i - 1] : undefined,
next: i < list.length - 1 ? list[i + 1] : undefined,
};
}
}
} catch (e) {
const cause = (e as any)?.cause;
error = `Could not connect to backend at ${API_URL}: ${e instanceof Error ? e.message : String(e)}${cause ? ' (Cause: ' + (cause.message || cause.code || JSON.stringify(cause)) + ')' : ''}`;
console.error(error);
}
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Post';
const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
const exhibitNumber = neighbors.index >= 0 ? toRoman(neighbors.index + 1) : '';
---
<Layout
title={displayTitle}
description={post?.summary}
image={post?.cover_image?.url}
type="article"
>
{/* Reading progress bar */}
<div
id="reading-progress"
class="fixed top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-mauve via-blue to-teal z-[150] origin-left"
style="transform: scaleX(0); transition: transform 80ms linear;"
aria-hidden="true"
></div>
<div id="reading-progress" class="reading-progress" aria-hidden="true"></div>
{error && (
<div class="max-w-2xl mx-auto py-16 md:py-24 text-center">
<h2 class="text-2xl md:text-3xl font-bold text-red mb-4">{error}</h2>
<a href="/" class="inline-flex items-center gap-2 text-blue hover:text-sky transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
Back home
</a>
<div class="max-w-2xl mx-auto py-20 md:py-32 text-center">
<div class="font-display italic text-[var(--subtext0)] text-sm tracking-[0.3em] uppercase mb-4">Pardon —</div>
<h2 class="font-display italic text-3xl md:text-5xl text-[var(--mauve)] mb-6 leading-tight">{error}</h2>
<a href="/" class="btn-ghost">← Return to the catalogue</a>
</div>
)}
{post && (
<article class="animate-in fade-in slide-in-from-bottom-2 duration-500">
{/* Toolbar: Back to list + admin actions */}
<div class="flex items-center justify-between gap-3 mb-8 md:mb-12">
<a
href="/"
class="inline-flex items-center gap-2 text-subtext0 hover:text-text transition-colors text-sm group"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:-translate-x-1" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
Back to list
<article class="plate-enter">
{/* Toolbar — exhibit nav */}
<div class="flex items-center justify-between gap-3 mb-10 md:mb-14 font-display italic text-sm text-[var(--subtext0)]">
<a href="/" class="inline-flex items-center gap-2 hover:text-[var(--mauve)] transition-colors group">
<span class="transition-transform group-hover:-translate-x-1">←</span>
Back to catalogue
</a>
{isAdmin && (
<div class="flex items-center gap-2">
<a
href={`/admin/editor?edit=${encodeURIComponent(post.slug)}`}
class="inline-flex items-center gap-2 bg-surface0 hover:bg-blue/15 text-subtext1 hover:text-blue px-3 py-1.5 md:px-4 md:py-2 rounded-md border border-surface1 hover:border-blue/30 transition-colors text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
<a href={`/admin/editor?edit=${encodeURIComponent(post.slug)}`} class="btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
Edit
</a>
<DeletePostButton slug={post.slug} title={displayTitle} client:load />
@@ -97,50 +121,90 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Post';
)}
</div>
{/* Hero header — centered title + meta */}
<header class="max-w-3xl mx-auto text-center mb-10 md:mb-16">
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 mb-4 text-xs md:text-sm text-subtext0">
<time datetime={post.date}>{formatDate(post.date)}</time>
<span class="opacity-50">·</span>
<span>{post.reading_time} min read</span>
{post.draft && (
<>
<span class="opacity-50">·</span>
<span class="text-peach uppercase tracking-wide font-semibold">Draft</span>
</>
)}
{/* Plaque header */}
<header class="max-w-3xl mx-auto text-center mb-12 md:mb-16">
{exhibitNumber && (
<div class="font-display italic text-[var(--mauve)] tracking-[0.3em] text-sm mb-5">
№ {exhibitNumber} <span class="text-[var(--subtext0)] not-italic">/ {neighbors.total}</span>
</div>
<h1 class="text-3xl md:text-5xl lg:text-6xl font-extrabold text-mauve leading-[1.1] mb-6 md:mb-8 tracking-tight">
)}
<h1 class="font-display italic font-semibold text-[var(--text)] text-4xl md:text-6xl lg:text-7xl leading-[0.95] tracking-tight mb-6">
{displayTitle}
</h1>
<div class="section-rule max-w-md mx-auto mb-6">
<span class="ornament">✦</span>
<span>{formatDate(post.date)}</span>
<span class="ornament">·</span>
<span>{post.reading_time} min</span>
{post.image_count > 0 && (
<>
<span class="ornament">·</span>
<span>{post.image_count} {post.image_count === 1 ? 'plate' : 'plates'}</span>
</>
)}
<span class="ornament">✦</span>
</div>
{post.summary && (
<p class="text-base md:text-xl text-subtext1 leading-relaxed max-w-2xl mx-auto">
<p class="font-display italic text-[var(--subtext1)] text-lg md:text-xl leading-relaxed max-w-2xl mx-auto">
{post.summary}
</p>
)}
{post.draft && (
<div class="mt-6 inline-block">
<span class="chip" style="background: var(--peach); color: var(--crust); border-color: var(--peach);">
Sketch · unpublished
</span>
</div>
)}
{post.tags?.length > 0 && (
<div class="flex flex-wrap justify-center gap-2 mt-6">
{post.tags.map(tag => (
<span class="text-[10px] uppercase tracking-wider px-2.5 py-1 rounded-full bg-surface0 text-subtext0 border border-surface1">{tag}</span>
))}
{post.tags.map(tag => <span class="chip">{tag}</span>)}
</div>
)}
</header>
<div class="w-full max-w-3xl mx-auto h-px bg-gradient-to-r from-transparent via-surface2 to-transparent mb-10 md:mb-16"></div>
{/* Body — works on paper */}
<div id="post-content" class="prose" set:html={html} />
{/* Body */}
<div id="post-content" class="prose px-1" set:html={html} />
{/* Footer separator + back link */}
<div class="max-w-3xl mx-auto mt-16 md:mt-24 pt-8 md:pt-12 border-t border-surface1/60 text-center">
<a
href="/"
class="inline-flex items-center gap-2 text-subtext0 hover:text-mauve transition-colors text-sm group"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:-translate-x-1" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
Back to all posts
{/* Closing — continue the room */}
<div class="max-w-3xl mx-auto mt-20 md:mt-28">
<div class="section-rule mb-10">
<span class="ornament">✦</span>
<span>continue the gallery</span>
<span class="ornament">✦</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{neighbors.prev && (
<a href={`/posts/${encodeURIComponent(neighbors.prev.slug)}`} class="group glass p-6 hover:border-[var(--mauve)] transition-colors text-left">
<div class="font-sans text-xs tracking-[0.22em] uppercase text-[var(--subtext0)] mb-2">← Previously hung</div>
<div class="font-display italic text-xl text-[var(--text)] group-hover:text-[var(--mauve)] transition-colors leading-snug">
{neighbors.prev.title || formatSlug(neighbors.prev.slug)}
</div>
</a>
)}
{neighbors.next && (
<a href={`/posts/${encodeURIComponent(neighbors.next.slug)}`} class="group glass p-6 hover:border-[var(--mauve)] transition-colors text-right md:col-start-2">
<div class="font-sans text-xs tracking-[0.22em] uppercase text-[var(--subtext0)] mb-2">Next on the wall →</div>
<div class="font-display italic text-xl text-[var(--text)] group-hover:text-[var(--mauve)] transition-colors leading-snug">
{neighbors.next.title || formatSlug(neighbors.next.slug)}
</div>
</a>
)}
{!neighbors.prev && !neighbors.next && (
<div class="md:col-span-2 text-center font-display italic text-[var(--subtext0)]">
This is the sole work currently on view.
</div>
)}
</div>
<div class="mt-12 text-center">
<a href="/" class="btn-ghost">↶ Return to catalogue</a>
</div>
</div>
</article>
)}
+679 -154
View File
@@ -1,8 +1,9 @@
@import "tailwindcss";
/*
* NARLBLOG THEME ENGINE
* All UI components automatically pick up these tokens.
* SALON HANG — gallery theme.
* Aged parchment ground, oxblood ink, ochre+cobalt+vermillion accents.
* Romantic gravity (Friedrich, Dix, Goya) + raw scrawl (Basquiat) + bold cutout (Matisse, Kahlo).
*/
@theme {
@@ -33,11 +34,75 @@
--color-flamingo: var(--flamingo);
--color-rosewater: var(--rosewater);
--font-sans: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-sans: 'EB Garamond Variable', 'EB Garamond', Georgia, 'Times New Roman', serif;
--font-display: 'Fraunces Variable', 'Fraunces', Georgia, 'Times New Roman', serif;
--font-hand: 'Caveat', 'Bradley Hand', cursive;
--font-mono: 'JetBrains Mono Variable', ui-monospace, 'SF Mono', Menlo, monospace;
}
:root, .mocha {
/* SALON — default. Aged parchment with romantic weight. */
:root, .salon {
--crust: #14100C;
--mantle: #2A1F18;
--base: #ECE0C6;
--surface0: #DDCEB0;
--surface1: #B69C70;
--surface2: #826846;
--overlay0: #5C463A;
--overlay1: #463226;
--overlay2: #2E1F17;
--text: #1A120C;
--subtext0: #5C463A;
--subtext1: #3D2B1E;
/* accents — mapped to the original token names so existing UI flows pick them up */
--blue: #1F3A78; /* Kahlo cobalt */
--lavender: #5C4D7A; /* faded violet */
--sapphire: #2B3E5C; /* deep ink-blue */
--sky: #4A6FA0; /* muted azure */
--teal: #4C7264; /* verdigris */
--green: #6A7341; /* olive */
--yellow: #C9882B; /* Friedrich ochre */
--peach: #C26847; /* terracotta */
--maroon: #6B2B2A; /* wine */
--red: #B83A2B; /* Matisse/Goya vermillion */
--mauve: #6B1F1A; /* oxblood — primary accent */
--pink: #B85A6C; /* rosehip */
--flamingo: #C77A6C; /* faded coral */
--rosewater: #E8D9BD; /* bone */
}
/* Salon Noir — black gallery wall variant (Goya black paintings, Abramović stark). */
.salon-noir {
--crust: #050402;
--mantle: #0E0A06;
--base: #16110B;
--surface0: #221A12;
--surface1: #3A2B1E;
--surface2: #5C4530;
--overlay0: #7A5D43;
--overlay1: #93755A;
--overlay2: #B69779;
--text: #ECE0C6;
--subtext0: #B69C70;
--subtext1: #D6C49E;
--blue: #5A7DC4;
--lavender: #9A8DBE;
--sapphire: #87A9D8;
--sky: #B0C4E0;
--teal: #84A89A;
--green: #B9C076;
--yellow: #E9B854;
--peach: #E89570;
--maroon: #A04A47;
--red: #E25940;
--mauve: #C24336; /* lifted oxblood for dark bg contrast */
--pink: #E090A0;
--flamingo: #EBA797;
--rosewater: #F4E5C9;
}
/* Legacy Catppuccin themes — kept for users that already opted in. */
.mocha {
--crust: #11111b; --mantle: #181825; --base: #1e1e2e;
--surface0: #313244; --surface1: #45475a; --surface2: #585b70;
--overlay0: #6c7086; --overlay1: #7f849c; --overlay2: #9399b2;
@@ -49,31 +114,6 @@
--flamingo: #f2cdcd; --rosewater: #f5e0dc;
}
.macchiato {
--crust: #181926; --mantle: #1e2030; --base: #24273a;
--surface0: #363a4f; --surface1: #494d64; --surface2: #5b6078;
--overlay0: #6e738d; --overlay1: #8087a2; --overlay2: #939ab7;
--text: #cad3f5; --subtext0: #a5adcb; --subtext1: #b8c0e0;
--blue: #8aadf4; --lavender: #b7bdf8; --sapphire: #7dc4e4;
--sky: #91d7e3; --teal: #8bd5ca; --green: #a6da95;
--yellow: #eed49f; --peach: #f5a97f; --maroon: #ee99a0;
--red: #ed8796; --mauve: #c6a0f6; --pink: #f5bde6;
--flamingo: #f0c1c1; --rosewater: #f4dbd6;
}
.frappe {
--crust: #232634; --mantle: #292c3c; --base: #303446;
--surface0: #414559; --surface1: #51576d; --surface2: #626880;
--overlay0: #737994; --overlay1: #838ba7; --overlay2: #949cbb;
--text: #c6d0f5; --subtext0: #a5adce; --subtext1: #b5bfe3;
--blue: #8caaee; --lavender: #babbf1; --sapphire: #85c1dc;
--sky: #99d1db; --teal: #81c8be; --green: #a6d189;
--yellow: #e5c890; --peach: #ef9f76; --maroon: #ea999c;
--red: #e78284; --mauve: #ca9ee6; --pink: #f4b8e4;
--flamingo: #eebebe; --rosewater: #f2d5cf;
}
/* Light themes — darkened secondary text for WCAG AA against base. */
.latte {
--crust: #dce0e8; --mantle: #e6e9ef; --base: #eff1f5;
--surface0: #ccd0da; --surface1: #bcc0cc; --surface2: #acb0be;
@@ -86,20 +126,9 @@
--flamingo: #dd7878; --rosewater: #dc8a78;
}
.scaled-and-icy {
--crust: #e2e8f0; --mantle: #f1f5f9; --base: #f8fafc;
--surface0: #cbd5e1; --surface1: #94a3b8; --surface2: #64748b;
--overlay0: #475569; --overlay1: #334155; --overlay2: #1e293b;
--text: #0f172a; --subtext0: #1e293b; --subtext1: #334155;
--blue: #0284c7; --lavender: #6366f1; --sapphire: #0ea5e9;
--sky: #0284c7; --teal: #0d9488; --green: #16a34a;
--yellow: #ca8a04; --peach: #ea580c; --maroon: #be123c;
--red: #dc2626; --mauve: #9333ea; --pink: #db2777;
--flamingo: #be185d; --rosewater: #b91c1c;
}
html {
font-family: var(--font-sans);
font-feature-settings: "kern", "liga", "calt", "onum";
}
body {
@@ -108,194 +137,652 @@ body {
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased;
font-size: 1.0625rem;
line-height: 1.65;
position: relative;
}
/* Paper grain — applied as a fixed overlay so every page gets the texture. */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -2;
background-color: var(--base);
}
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.35;
mix-blend-mode: multiply;
background-image:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.07 0 0 0 0 0.04 0 0 0 0.22 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
}
.salon-noir body::after,
html.salon-noir body::after {
opacity: 0.5;
}
/* Floating motes of pigment — far background, very subtle. */
.salon-atmosphere {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
overflow: hidden;
}
.salon-atmosphere::before,
.salon-atmosphere::after {
content: "";
position: absolute;
border-radius: 50%;
filter: blur(120px);
opacity: 0.18;
}
.salon-atmosphere::before {
width: 55vw; height: 55vw;
top: -15vw; left: -10vw;
background: var(--mauve);
}
.salon-atmosphere::after {
width: 45vw; height: 45vw;
bottom: -10vw; right: -10vw;
background: var(--blue);
opacity: 0.12;
}
code, pre, kbd, samp {
font-family: var(--font-mono);
}
/* Prose — readable column, calm hierarchy */
/* Selection */
::selection {
background: var(--mauve);
color: var(--rosewater);
}
/* ───── Display utilities ───── */
.font-display {
font-family: var(--font-display);
font-feature-settings: "kern", "liga", "calt", "lnum", "ss01";
letter-spacing: -0.01em;
}
.font-hand {
font-family: var(--font-hand);
font-weight: 400;
}
.font-display-italic {
font-family: var(--font-display);
font-style: italic;
font-feature-settings: "kern", "liga", "calt", "ss01";
}
/* Roman numerals get small-caps treatment */
.numeral {
font-family: var(--font-display);
font-variant-numeric: lining-nums;
letter-spacing: 0.08em;
font-weight: 500;
}
/* ───── Salon prose — exhibit plaque body ───── */
.prose {
color: var(--text);
max-width: 72ch;
max-width: 62ch;
margin-left: auto;
margin-right: auto;
line-height: 1.75;
font-size: 1.05rem;
font-size: 1.125rem;
font-family: var(--font-sans);
}
@media (min-width: 768px) {
.prose {
font-size: 1.0625rem;
}
.prose { font-size: 1.1875rem; }
}
.prose h1 {
font-size: clamp(1.75rem, 1.4rem + 1.5vw, 2.5rem);
font-weight: 700;
.prose > *:first-child { margin-top: 0; }
.prose p:first-of-type::first-letter {
font-family: var(--font-display);
font-weight: 600;
font-size: 3.6em;
line-height: 0.85;
float: left;
margin: 0.08em 0.12em 0 -0.04em;
color: var(--mauve);
margin: 0 0 1rem;
line-height: 1.2;
}
.prose h1 {
font-family: var(--font-display);
font-size: clamp(2rem, 1.5rem + 2vw, 3rem);
font-weight: 600;
font-style: italic;
color: var(--text);
margin: 0 0 1.25rem;
line-height: 1.05;
letter-spacing: -0.02em;
}
.prose h2 {
font-size: clamp(1.4rem, 1.2rem + 0.8vw, 1.875rem);
font-weight: 600;
font-family: var(--font-display);
font-size: clamp(1.5rem, 1.2rem + 1vw, 2rem);
font-weight: 500;
color: var(--text);
margin: 2.5rem 0 1rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid color-mix(in srgb, var(--surface2) 60%, transparent);
line-height: 1.3;
margin: 3rem 0 1rem;
line-height: 1.2;
letter-spacing: -0.01em;
}
.prose h2::before {
content: "§ ";
color: var(--mauve);
font-style: italic;
opacity: 0.7;
}
.prose h3 {
font-size: 1.35rem;
font-weight: 600;
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 500;
font-style: italic;
color: var(--text);
margin: 2rem 0 0.75rem;
line-height: 1.35;
margin: 2.25rem 0 0.75rem;
}
.prose h4 {
font-size: 1.15rem;
font-weight: 600;
color: var(--text);
margin: 1.5rem 0 0.5rem;
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 500;
color: var(--subtext1);
margin: 1.75rem 0 0.5rem;
}
.prose h5 {
font-size: 1rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--subtext1);
color: var(--subtext0);
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.18em;
margin: 1.5rem 0 0.5rem;
}
.prose h6 {
font-size: 0.9rem;
font-weight: 600;
color: var(--subtext0);
font-size: 0.85rem;
font-weight: 500;
color: var(--overlay0);
font-style: italic;
margin: 1rem 0 0.5rem;
}
.prose p {
margin: 0 0 1.1rem;
}
.prose p { margin: 0 0 1.15rem; }
.prose blockquote {
border-left: 3px solid var(--mauve);
padding: 0.25rem 0 0.25rem 1.1rem;
margin: 1.5rem 0;
border-left: 3px double var(--mauve);
padding: 0.5rem 0 0.5rem 1.4rem;
margin: 1.75rem 0;
color: var(--subtext1);
font-family: var(--font-display);
font-style: italic;
}
.prose pre {
padding: 1rem 1.1rem;
border-radius: 0.75rem;
overflow-x: auto;
border: 1px solid color-mix(in srgb, var(--surface1) 70%, transparent);
margin: 1.5rem 0;
background-color: var(--crust);
font-size: 0.9rem;
font-size: 1.15em;
line-height: 1.55;
}
.prose blockquote::before {
content: "“";
font-family: var(--font-display);
color: var(--mauve);
font-size: 2.5em;
line-height: 0;
vertical-align: -0.35em;
margin-right: 0.1em;
opacity: 0.55;
}
.prose pre {
padding: 1rem 1.1rem;
border-radius: 0;
border: 1px solid var(--surface2);
border-left-width: 3px;
border-left-color: var(--mauve);
margin: 1.75rem 0;
background-color: color-mix(in srgb, var(--surface0) 70%, transparent);
font-size: 0.875rem;
line-height: 1.55;
overflow-x: auto;
}
.prose code {
background-color: color-mix(in srgb, var(--surface0) 80%, transparent);
padding: 0.15rem 0.4rem;
border-radius: 0.3rem;
background-color: color-mix(in srgb, var(--surface0) 90%, transparent);
padding: 0.1rem 0.4rem;
border-radius: 0;
border-bottom: 1px solid var(--surface1);
font-size: 0.9em;
color: var(--peach);
color: var(--mauve);
}
.prose pre code {
background: transparent;
padding: 0;
border-radius: 0;
border: 0;
color: inherit;
font-size: inherit;
}
.prose img {
max-width: 100%;
height: auto;
border-radius: 0.75rem;
margin: 1.75rem 0;
border: 1px solid color-mix(in srgb, var(--surface1) 50%, transparent);
}
.prose a {
color: var(--blue);
color: var(--mauve);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: var(--surface1);
text-decoration-thickness: 1px;
transition: color 0.15s;
text-underline-offset: 3px;
transition: color 0.15s, text-decoration-color 0.15s;
}
.prose a:hover {
color: var(--sky);
color: var(--red);
text-decoration-color: var(--red);
}
.prose ul, .prose ol {
margin: 0 0 1.1rem;
padding-left: 1.5rem;
}
.prose ul { list-style: disc; }
.prose ol { list-style: decimal; }
.prose ul ul { list-style: circle; }
.prose ul ul ul { list-style: square; }
.prose li { margin: 0.25rem 0; }
.prose hr {
margin: 2.5rem 0;
border: 0;
border-top: 1px solid color-mix(in srgb, var(--surface2) 70%, transparent);
}
.prose strong { color: var(--text); font-weight: 700; }
.prose em { color: inherit; font-style: italic; }
.prose del { color: var(--overlay1); text-decoration: line-through; }
.prose li input[type="checkbox"] { margin-right: 0.5rem; accent-color: var(--blue); vertical-align: middle; }
.prose li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.25rem; }
/* GFM tables */
.prose ul, .prose ol {
margin: 0 0 1.15rem;
padding-left: 1.6rem;
}
.prose ul { list-style: none; }
.prose ul > li { position: relative; padding-left: 0.2rem; }
.prose ul > li::before {
content: "❦";
position: absolute;
left: -1.2rem;
color: var(--mauve);
font-size: 0.85em;
top: 0.05em;
}
.prose ol { list-style: decimal-leading-zero; }
.prose ol > li::marker { color: var(--mauve); font-family: var(--font-display); font-style: italic; }
.prose li { margin: 0.3rem 0; }
.prose hr {
margin: 3rem auto;
border: 0;
text-align: center;
height: 1em;
}
.prose hr::before {
content: "✦ ✦ ✦";
color: var(--surface2);
letter-spacing: 0.8em;
font-size: 0.85em;
}
.prose strong { color: var(--mauve); font-weight: 700; }
.prose em { color: inherit; font-style: italic; font-family: var(--font-display); }
.prose del { color: var(--overlay0); text-decoration: line-through; }
/* ───── Figure / image plate — the heart of the gallery body ───── */
.prose figure,
.prose p > img:only-child {
margin: 2.5rem 0;
}
.prose figure {
display: block;
text-align: center;
}
.prose figure img,
.prose img {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
border: 1px solid var(--surface2);
padding: 6px;
background:
linear-gradient(var(--rosewater), var(--rosewater)) padding-box,
linear-gradient(135deg, var(--surface2), var(--surface1)) border-box;
box-shadow:
0 1px 0 var(--surface0),
0 18px 38px -22px rgba(20, 16, 12, 0.45),
0 2px 6px -2px rgba(20, 16, 12, 0.2);
border-radius: 2px;
}
.salon-noir .prose figure img,
.salon-noir .prose img {
background:
linear-gradient(var(--surface0), var(--surface0)) padding-box,
linear-gradient(135deg, var(--surface2), var(--surface1)) border-box;
box-shadow:
0 18px 38px -22px rgba(0, 0, 0, 0.7),
0 2px 6px -2px rgba(0, 0, 0, 0.5);
}
.prose figure figcaption {
font-family: var(--font-display);
font-style: italic;
font-size: 0.9rem;
color: var(--subtext0);
margin-top: 0.85rem;
letter-spacing: 0.02em;
line-height: 1.4;
}
.prose figure figcaption::before {
content: "— ";
color: var(--mauve);
}
/* GFM tables — keep, slightly more editorial */
.prose table {
width: 100%;
margin: 1.75rem 0;
border-collapse: collapse;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid var(--surface1);
font-size: 0.9rem;
border: 1px solid var(--surface2);
font-size: 0.95rem;
font-family: var(--font-sans);
}
.prose thead { background-color: color-mix(in srgb, var(--surface0) 60%, transparent); }
.prose thead { background-color: color-mix(in srgb, var(--surface0) 80%, transparent); }
.prose th {
padding: 0.6rem 0.9rem;
padding: 0.55rem 0.9rem;
text-align: left;
font-weight: 600;
color: var(--text);
border-bottom: 1px solid var(--surface1);
border-bottom: 1px solid var(--surface2);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.75rem;
}
.prose td {
padding: 0.5rem 0.9rem;
border-bottom: 1px solid color-mix(in srgb, var(--surface1) 60%, transparent);
}
.prose tr:last-child td { border-bottom: 0; }
.prose tr:nth-child(even) td { background-color: color-mix(in srgb, var(--surface0) 25%, transparent); }
/* Glass surface */
/* ───── Salon plate — a single framed image card used on the gallery index ───── */
.plate {
position: relative;
background: var(--rosewater);
padding: 14px 14px 0 14px;
border: 1px solid var(--surface2);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 50%, transparent),
0 1px 0 var(--surface0),
0 22px 42px -28px rgba(20, 16, 12, 0.5),
0 4px 12px -6px rgba(20, 16, 12, 0.25);
border-radius: 2px;
transition: transform 0.4s cubic-bezier(0.2, 0.6, 0.2, 1),
box-shadow 0.4s cubic-bezier(0.2, 0.6, 0.2, 1);
}
.salon-noir .plate {
background: var(--surface0);
}
.plate:hover {
transform: translateY(-4px) rotate(-0.25deg);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 60%, transparent),
0 1px 0 var(--surface0),
0 32px 60px -28px rgba(20, 16, 12, 0.55),
0 8px 20px -8px rgba(20, 16, 12, 0.3);
}
.plate .plate-image {
position: relative;
overflow: hidden;
background: var(--mantle);
}
.plate .plate-image img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
filter: saturate(0.94) contrast(1.02);
transition: transform 0.8s cubic-bezier(0.2, 0.6, 0.2, 1), filter 0.4s ease;
}
.plate:hover .plate-image img {
transform: scale(1.03);
filter: saturate(1.05) contrast(1.04);
}
.plate .plate-caption {
padding: 12px 4px 14px 4px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
}
.plate .plate-caption-title {
font-family: var(--font-display);
font-style: italic;
font-weight: 500;
font-size: 1.05rem;
line-height: 1.2;
color: var(--text);
letter-spacing: -0.005em;
}
.plate .plate-caption-meta {
font-family: var(--font-sans);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--subtext0);
white-space: nowrap;
align-self: flex-start;
padding-top: 0.35rem;
}
/* The little exhibit number stuck to the corner of a plate */
.plate-tag {
position: absolute;
top: -10px;
left: 14px;
background: var(--mauve);
color: var(--rosewater);
font-family: var(--font-display);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.18em;
padding: 4px 8px;
text-transform: uppercase;
box-shadow: 0 2px 6px -2px rgba(20, 16, 12, 0.45);
}
.plate-tag-mini {
position: absolute;
bottom: 18px;
right: 18px;
background: rgba(20, 16, 12, 0.78);
color: var(--rosewater);
font-family: var(--font-display);
font-size: 0.7rem;
letter-spacing: 0.16em;
padding: 3px 8px;
text-transform: uppercase;
backdrop-filter: blur(2px);
}
/* Nameplate — the museum-style header used in the site chrome */
.nameplate {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
position: relative;
}
.nameplate::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -6px;
height: 2px;
background: linear-gradient(to right,
var(--mauve) 0%,
var(--mauve) 35%,
var(--surface2) 35%,
var(--surface2) 100%);
}
.nameplate-title {
font-family: var(--font-display);
font-weight: 600;
font-style: italic;
font-size: 1.6rem;
letter-spacing: -0.01em;
color: var(--text);
line-height: 1;
}
.nameplate-subtitle {
font-family: var(--font-sans);
font-size: 0.65rem;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--subtext0);
}
/* Section ornaments */
.section-rule {
display: flex;
align-items: center;
gap: 1rem;
color: var(--subtext0);
font-family: var(--font-display);
font-style: italic;
font-size: 0.95rem;
letter-spacing: 0.04em;
}
.section-rule::before,
.section-rule::after {
content: "";
flex: 1;
height: 1px;
background: var(--surface2);
}
.section-rule .ornament {
color: var(--mauve);
}
/* Scrawled handwritten margin notes */
.scrawl {
font-family: var(--font-hand);
color: var(--mauve);
font-size: 1.4rem;
line-height: 1;
transform: rotate(-6deg);
display: inline-block;
}
.scrawl-mark::before {
content: "✕";
font-family: var(--font-hand);
color: var(--red);
margin-right: 0.35em;
}
/* Stripe (Matisse cutout) chip used for tags */
.chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-display);
font-style: italic;
font-size: 0.78rem;
padding: 0.15rem 0.6rem;
background: color-mix(in srgb, var(--surface0) 80%, transparent);
border: 1px solid var(--surface2);
color: var(--subtext1);
border-radius: 1px;
letter-spacing: 0.02em;
}
.chip-accent {
background: var(--mauve);
color: var(--rosewater);
border-color: var(--mauve);
}
/* Card / glass — keep the name but reinterpret as a paper card */
.glass {
background-color: color-mix(in srgb, var(--surface0) 75%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.25);
border-radius: 1rem;
background-color: color-mix(in srgb, var(--surface0) 80%, transparent);
border: 1px solid var(--surface2);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 50%, transparent),
0 10px 30px -20px rgba(20, 16, 12, 0.45);
border-radius: 2px;
}
.salon-noir .glass {
background-color: color-mix(in srgb, var(--surface0) 70%, transparent);
}
/* Don't double-blur nested glass surfaces */
.glass .glass {
background-color: color-mix(in srgb, var(--surface0) 50%, transparent);
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: none;
/* ───── Buttons ───── */
.btn-stamp {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-display);
font-style: italic;
font-weight: 500;
font-size: 0.95rem;
padding: 0.55rem 1.2rem;
background: var(--mauve);
color: var(--rosewater);
border: 1px solid var(--mauve);
border-radius: 1px;
letter-spacing: 0.02em;
text-decoration: none;
cursor: pointer;
transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
box-shadow: 0 4px 0 -2px color-mix(in srgb, var(--mauve) 60%, black);
}
.btn-stamp:hover {
transform: translateY(-1px);
background: var(--red);
border-color: var(--red);
box-shadow: 0 6px 0 -2px color-mix(in srgb, var(--red) 60%, black);
}
.btn-stamp:active {
transform: translateY(1px);
box-shadow: 0 1px 0 -1px color-mix(in srgb, var(--mauve) 60%, black);
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-sans);
font-size: 0.82rem;
padding: 0.4rem 0.85rem;
background: transparent;
color: var(--subtext1);
border: 1px solid var(--surface2);
border-radius: 1px;
letter-spacing: 0.06em;
text-transform: uppercase;
text-decoration: none;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.btn-ghost:hover {
color: var(--mauve);
border-color: var(--mauve);
background: color-mix(in srgb, var(--mauve) 8%, transparent);
}
/* hljs token colors — driven by theme tokens */
/* Form input look */
.field-input {
width: 100%;
background: color-mix(in srgb, var(--surface0) 60%, transparent);
border: 1px solid var(--surface2);
border-radius: 1px;
padding: 0.65rem 0.9rem;
color: var(--text);
font-family: var(--font-sans);
font-size: 1rem;
transition: border-color 0.15s, background 0.15s;
}
.field-input:focus {
outline: none;
border-color: var(--mauve);
background: color-mix(in srgb, var(--rosewater) 70%, transparent);
}
.field-label {
display: block;
font-family: var(--font-sans);
font-size: 0.72rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--subtext0);
margin-bottom: 0.4rem;
}
/* hljs token colors — driven by theme tokens, slightly muted for parchment bg */
.hljs { color: var(--text); background: transparent; }
.hljs-keyword, .hljs-selector-tag, .hljs-built_in { color: var(--mauve); font-weight: 600; }
.hljs-string, .hljs-attr { color: var(--green); }
.hljs-number, .hljs-literal { color: var(--peach); }
.hljs-comment, .hljs-quote { color: var(--overlay1); font-style: italic; }
.hljs-comment, .hljs-quote { color: var(--overlay0); font-style: italic; }
.hljs-title, .hljs-section, .hljs-name { color: var(--blue); }
.hljs-type, .hljs-class .hljs-title { color: var(--yellow); }
.hljs-variable, .hljs-template-variable { color: var(--red); }
/* KaTeX inherits prose color */
/* KaTeX */
.katex { color: var(--text); }
/* Skeleton loader */
@@ -308,7 +795,7 @@ code, pre, kbd, samp {
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
border-radius: 0.5rem;
border-radius: 1px;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
@@ -321,13 +808,15 @@ code, pre, kbd, samp {
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
background: var(--surface0);
border: 1px solid var(--surface1);
color: var(--text);
background: var(--mantle);
border: 1px solid var(--surface2);
color: var(--rosewater);
padding: 0.65rem 1.1rem;
border-radius: 0.6rem;
box-shadow: 0 8px 24px -8px rgba(0,0,0,0.4);
font-size: 0.85rem;
border-radius: 1px;
box-shadow: 0 12px 30px -10px rgba(0, 0, 0, 0.45);
font-family: var(--font-display);
font-style: italic;
font-size: 0.9rem;
z-index: 200;
animation: toast-in 0.2s ease;
}
@@ -335,3 +824,39 @@ code, pre, kbd, samp {
from { opacity: 0; transform: translate(-50%, 8px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
/* Salon grid spans driven by --col-span custom prop (avoids Tailwind dynamic class issue). */
@media (min-width: 768px) {
.md-col-span {
grid-column: span var(--col-span, 6) / span var(--col-span, 6);
}
}
/* Subtle page enter animation for gallery / plaque */
@keyframes plate-fade-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.plate-enter {
opacity: 0;
animation: plate-fade-up 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
}
/* Custom checkbox accent for form bits inside the salon */
input[type="checkbox"] { accent-color: var(--mauve); }
input[type="date"] { color-scheme: light; }
.salon-noir input[type="date"] { color-scheme: dark; }
/* Reading progress bar - thin terracotta line */
.reading-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--mauve);
z-index: 150;
transform-origin: left;
transform: scaleX(0);
transition: transform 80ms linear;
}