Compare commits

..

26 Commits

Author SHA1 Message Date
nvrl 0b24cfeeac update sigil
CI / frontend (push) Failing after 1s
CI / backend (push) Failing after 1s
2026-05-21 05:02:44 +02:00
nvrl 9d326c7f29 added boot
CI / frontend (push) Failing after 1s
CI / backend (push) Failing after 0s
2026-05-21 04:50:06 +02:00
nvrl f7d1620d08 updated sigil
CI / frontend (push) Failing after 0s
CI / backend (push) Failing after 0s
2026-05-21 04:48:32 +02:00
nvrl 3f2c39e000 removed jitter
CI / frontend (push) Failing after 1s
CI / backend (push) Failing after 0s
2026-05-21 04:46:47 +02:00
nvrl 02e15ea222 updated cybersigil
CI / frontend (push) Failing after 1s
CI / backend (push) Failing after 1s
2026-05-21 04:45:34 +02:00
nvrl 3680a12761 updated cybersigil 2026-05-21 04:44:27 +02:00
nvrl c6c9819ab0 updated cybersigil
CI / frontend (push) Failing after 1s
CI / backend (push) Failing after 1s
2026-05-21 04:43:31 +02:00
nvrl 0d518e94d4 update cybersigilism
CI / frontend (push) Failing after 0s
CI / backend (push) Failing after 0s
2026-05-21 04:41:12 +02:00
nvrl eba4543953 added just + cybersigilism update
CI / frontend (push) Failing after 1s
CI / backend (push) Failing after 0s
2026-05-21 04:39:44 +02:00
nvrl 0da5b24dc3 updated readme + just
CI / frontend (push) Failing after 0s
CI / backend (push) Failing after 1s
2026-05-21 04:29:43 +02:00
nvrl 37f88f5ad1 fix blog without image
CI / frontend (push) Failing after 1s
CI / backend (push) Failing after 1s
2026-05-21 04:09:02 +02:00
nvrl 4fae8b5e47 Merge pull request 'modified blog posts' (#2) from ela into main
CI / frontend (push) Successful in 42s
CI / backend (push) Failing after 20s
Reviewed-on: #2
2026-05-18 14:34:12 +02:00
nvrl 0bd27dd7ef modified blog posts
CI / frontend (pull_request) Successful in 36s
CI / backend (pull_request) Failing after 20s
2026-05-18 14:33:58 +02:00
nvrl 39bbd9e925 changed ports
CI / frontend (push) Successful in 38s
CI / backend (push) Failing after 19s
2026-05-18 14:24:51 +02:00
nvrl 72c09a2ff2 merged 2026-05-18 14:16:48 +02:00
nvrl aec2bf66fd Merge pull request 'init elas atelier' (#1) from ela into main
CI / frontend (push) Successful in 37s
CI / backend (push) Failing after 18s
Reviewed-on: #1
2026-05-18 13:55:41 +02:00
nvrl 2cfd3ff779 added public api url
CI / frontend (pull_request) Successful in 55s
CI / backend (pull_request) Failing after 34s
2026-05-18 13:54:29 +02:00
nvrl 86f855493b added site modes 2026-05-18 13:51:33 +02:00
nvrl c3aa52ddfd updated markdown style 2026-05-18 12:34:12 +02:00
nvrl 5985f172a1 fixed editor buttons 2026-05-18 12:29:25 +02:00
nvrl a1e3c2329e disabled paralex 2026-05-18 12:22:23 +02:00
nvrl b38b86e5ab fixed justify and improved sigils 2026-05-18 12:14:28 +02:00
nvrl 2651b29d02 restored 4 corner sigils 2026-05-17 17:10:14 +02:00
nvrl b64cd2e85a updated sigil rendering 2026-05-17 16:11:36 +02:00
nvrl 04733eb00a edge sigil 2026-05-17 15:38:04 +02:00
nvrl dc8e3d55b1 fixed off center hover sigil 2026-05-17 15:34:15 +02:00
32 changed files with 1551 additions and 394 deletions
+7
View File
@@ -18,3 +18,10 @@ FRONTEND_ORIGIN=
# Frontend Configuration # Frontend Configuration
# URL of the backend API accessible from the frontend container. # URL of the backend API accessible from the frontend container.
PUBLIC_API_URL=http://backend:3000 PUBLIC_API_URL=http://backend:3000
# Presentation focus. Same skin either way (fonts, cybersigil/breakcore,
# paper grain, CyberFx). `atelier` = image-first gallery (justified plates,
# "plates" count). `blog` = writing-first (stacked rows, excerpt, reading
# time). Read server-side at render — no rebuild needed to switch.
# Anything other than `blog` falls back to atelier.
SITE_MODE=atelier
+29
View File
@@ -0,0 +1,29 @@
set dotenv-load := true
set export := true
default:
@just --list
# Install dependencies for both backend and frontend
setup:
cd backend && cargo fetch
cd frontend && npm install
# Build backend and frontend
build:
cd backend && cargo build
cd frontend && npm run build
# Run tests for both backend and frontend
test:
cd backend && cargo test
cd frontend && npm test
# Start the stack with Docker Compose
docker:
docker compose up --build
# Run backend and frontend for development
dev:
@echo "Starting backend and frontend..."
(cd backend && cargo run) & (cd frontend && npm run dev) & wait
+53 -64
View File
@@ -1,6 +1,6 @@
# Ela's Atelier # narlblog
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. A minimalist, filesystem-backed portfolio and blog engine. Rust/Axum API for the backend and Astro/React for the frontend.
``` ```
backend/ Rust + Axum API (filesystem-backed) backend/ Rust + Axum API (filesystem-backed)
@@ -10,102 +10,91 @@ data/ Runtime data — posts, uploads, config.json (host volume)
## Quick start ## Quick start
```sh 1. **Environment Setup**:
cp .env.example .env ```sh
# Generate a strong admin token: cp .env.example .env
echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env # Generate a strong admin token:
echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env
```
docker compose up --build 2. **Using Just**:
# → http://localhost:4321 This project uses `just` to simplify common tasks.
# → log in at /admin/login with the token from .env ```sh
``` just setup # Install backend and frontend dependencies
just dev # Run both backend and frontend locally in parallel
just docker # Start the full stack using Docker Compose
```
Make sure the `data/` host directory is owned by UID 1000 (the backend container's app user): 3. **Permissions**:
Ensure the `data/` host directory is owned by UID 1000:
```sh
sudo chown -R 1000:1000 data/
```
```sh ## Site Modes
sudo chown -R 1000:1000 data/
``` The engine supports two distinct modes via the `SITE_MODE` environment variable:
- **`atelier` (Default)**: Optimized for art portfolios. Requires at least one image per post; the first image serves as the cover plate in the gallery view.
- **`blog`**: A traditional text-focused blog layout. Images are optional, and the index prioritizes titles and excerpts.
## Commands
| Command | Description |
| -------------- | ------------------------------------------------ |
| `just setup` | Installs dependencies for backend and frontend. |
| `just build` | Builds production binaries and assets. |
| `just dev` | Runs both services locally for development. |
| `just docker` | Starts the stack using `docker compose`. |
| `just test` | Runs the test suite for both services. |
## Environment ## Environment
| Variable | Required | Default | Notes | | Variable | Required | Default | Notes |
| ----------------- | -------- | --------------------- | --------------------------------------------------------------------------- | | ----------------- | -------- | --------------------- | --------------------------------------------------------------------------- |
| `ADMIN_TOKEN` | yes | — | Long random string. Stored as an HttpOnly cookie after login. | | `ADMIN_TOKEN` | yes | — | Long random string. Stored as an HttpOnly cookie after login. |
| `SITE_MODE` | no | `atelier` | `atelier` (art portfolio) or `blog` (traditional blog). |
| `PORT` | no | `3000` | Backend port. | | `PORT` | no | `3000` | Backend port. |
| `DATA_DIR` | no | `/app/data` | Where posts/uploads/config live. | | `DATA_DIR` | no | `/app/data` | Where posts/uploads/config live. |
| `COOKIE_SECURE` | no | `true` | Set `false` only for local HTTP development. | | `COOKIE_SECURE` | no | `true` | Set `false` only for local HTTP development. |
| `FRONTEND_ORIGIN` | no | _empty_ | Set to your frontend's URL only if you expose the backend directly. | | `FRONTEND_ORIGIN` | no | _empty_ | Set to your frontend's URL only if you expose the backend directly. |
| `RUST_LOG` | no | `info` | tracing-subscriber filter. |
| `PUBLIC_API_URL` | no | `http://backend:3000` | Backend URL the Astro proxy hits server-to-server. | | `PUBLIC_API_URL` | no | `http://backend:3000` | Backend URL the Astro proxy hits server-to-server. |
## Local development ## Authoring
Backend: Posts are stored as markdown files in `data/posts/<slug>.md` with YAML frontmatter.
```sh
cd backend
ADMIN_TOKEN=devtoken COOKIE_SECURE=false DATA_DIR=../data cargo run
```
Frontend (separate terminal, Node ≥ 22.12):
```sh
cd frontend
npm install
npm run dev
# → http://localhost:4321 (proxies to PUBLIC_API_URL, default http://backend:3000)
```
For a fully local stack, set `PUBLIC_API_URL=http://localhost:3000` in `frontend/.env`.
## Authoring a work
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 ```markdown
--- ---
date: 2026-05-09 date: 2026-05-09
summary: Optional short caption shown beneath the plate on the index. title: My First Post
summary: Optional short caption or excerpt.
tags: tags:
- oil - general
- 2026 - 2026
draft: false draft: false
--- ---
# Untitled (charcoal on paper) # Content Title
![A view of the cliff at dawn](/uploads/cliff-dawn.jpg "Plate I — graphite, A3") Post content goes here. In `atelier` mode, include at least one image:
Notes on the piece: materials, references, what worked, what didn't. ![Alt text](/uploads/image.jpg "Caption")
![Detail of the foreground](/uploads/cliff-detail.jpg "Plate II — detail")
``` ```
- `draft: true` hides a work from the public catalogue and 404s for non-curators. - `draft: true` hides a post from the public index.
- Works are sorted by `date` descending on the index. - Access the web editor at `/admin/editor` after logging in at `/admin/login`.
- The web editor at `/admin/editor` writes the same format and updates atomically.
## Uploads ## Uploads
Allowlisted extensions: jpg, jpeg, png, webp, gif, avif, pdf, txt, md, mp3, wav, ogg, mp4, webm, mov. Magic bytes are checked against the extension. SVG and HTML are intentionally rejected — `/uploads/*` is served as-is, so any active content there would be XSS. Supported: jpg, jpeg, png, webp, gif, avif, pdf, txt, md, mp3, wav, ogg, mp4, webm, mov.
Max size: 50 MB.
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 ## Backups
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. The `data/` directory contains all state (posts, uploads, configuration). Backup this folder regularly.
```sh
rsync -av data/ backup-host:/path/to/gallery-data/
```
## Stack ## Stack
- **Backend**: Rust 2024 edition (requires 1.85+), axum 0.8, serde_yaml frontmatter, subtle constant-time auth, infer for upload sniffing - **Backend**: Rust 2024, Axum 0.8
- **Frontend**: Astro 6, React 19, Tailwind 4, marked + marked-katex-extension + marked-highlight + DOMPurify - **Frontend**: Astro 6, React 19, Tailwind 4
- **Editor**: CodeMirror 6 with vim mode and asset autocomplete - **Editor**: CodeMirror 6 with Vim mode
+1 -1
View File
@@ -85,7 +85,7 @@ pub async fn create_post(
} }
let images = extract_images(&payload.content); let images = extract_images(&payload.content);
if images.is_empty() { if images.is_empty() && state.site_mode == crate::models::SiteMode::Atelier {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"A gallery entry must include at least one image (![](url) in the markdown body)." "A gallery entry must include at least one image (![](url) in the markdown body)."
.to_string(), .to_string(),
+21 -2
View File
@@ -20,7 +20,7 @@ use tower_http::{
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::handlers::contact::RATE_LIMIT_WINDOW_MS; use crate::handlers::contact::RATE_LIMIT_WINDOW_MS;
use crate::models::{ImageDim, PostInfo}; use crate::models::{ImageDim, PostInfo, SiteMode};
pub struct CachedPost { pub struct CachedPost {
pub info: PostInfo, pub info: PostInfo,
@@ -31,6 +31,7 @@ pub struct AppState {
pub admin_token: String, pub admin_token: String,
pub data_dir: PathBuf, pub data_dir: PathBuf,
pub cookie_secure: bool, pub cookie_secure: bool,
pub site_mode: SiteMode,
pub post_lock: Mutex<()>, pub post_lock: Mutex<()>,
pub posts_cache: RwLock<Vec<CachedPost>>, pub posts_cache: RwLock<Vec<CachedPost>>,
pub image_dims_cache: RwLock<HashMap<String, ImageDim>>, pub image_dims_cache: RwLock<HashMap<String, ImageDim>>,
@@ -55,8 +56,25 @@ async fn main() {
let cookie_secure = env::var("COOKIE_SECURE") let cookie_secure = env::var("COOKIE_SECURE")
.map(|v| v != "false" && v != "0") .map(|v| v != "false" && v != "0")
.unwrap_or(true); .unwrap_or(true);
let site_mode = env::var("SITE_MODE")
.map(|v| {
if v.to_lowercase() == "blog" {
SiteMode::Blog
} else {
SiteMode::Atelier
}
})
.unwrap_or(SiteMode::Atelier);
info!("Initializing backend with data dir: {:?}", data_dir); info!(
"Initializing backend with data dir: {:?}, mode: {:?}",
data_dir,
if site_mode == SiteMode::Blog {
"blog"
} else {
"atelier"
}
);
let posts_dir = data_dir.join("posts"); let posts_dir = data_dir.join("posts");
let uploads_dir = data_dir.join("uploads"); let uploads_dir = data_dir.join("uploads");
@@ -71,6 +89,7 @@ async fn main() {
admin_token, admin_token,
data_dir, data_dir,
cookie_secure, cookie_secure,
site_mode,
post_lock: Mutex::new(()), post_lock: Mutex::new(()),
posts_cache: RwLock::new(Vec::new()), posts_cache: RwLock::new(Vec::new()),
image_dims_cache: RwLock::new(HashMap::new()), image_dims_cache: RwLock::new(HashMap::new()),
+13
View File
@@ -2,6 +2,19 @@ use chrono::NaiveDate;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SiteMode {
Blog,
Atelier,
}
impl Default for SiteMode {
fn default() -> Self {
Self::Atelier
}
}
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct ContactLink { pub struct ContactLink {
pub kind: String, pub kind: String,
-14
View File
@@ -1,14 +0,0 @@
---
date: 2026-05-09
summary: A second placeholder — layout smoke test.
tags:
- intro
draft: false
---
# Second placeholder
A second placeholder so the salon-hang layout has room to breathe with more than one plate. Remove or replace from `/admin`.
![Placeholder plate](/uploads/placeholder.jpg "replace me")
> "The painter constructs, the photographer discloses." — Susan Sontag
-14
View File
@@ -1,14 +0,0 @@
---
date: 2026-05-09
summary: Opening note for the gallery — what's on the walls, why these pieces.
tags:
- intro
draft: false
---
# Welcome to the gallery
This room collects work made on paper, canvas, and elsewhere — finished pieces alongside the studies that didn't make it.
![Placeholder plate](/uploads/placeholder.jpg "replace with a real plate from /admin/assets")
Replace this entry with your first real work, or remove it from the catalogue via the admin dashboard.
+11 -9
View File
@@ -1,8 +1,8 @@
name: elas-atelier name: narlblog
services: services:
backend: backend:
container_name: elas-atelier-backend container_name: narlblog-backend
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -17,10 +17,11 @@ services:
- DATA_DIR=/app/data - DATA_DIR=/app/data
- COOKIE_SECURE=${COOKIE_SECURE:-true} - COOKIE_SECURE=${COOKIE_SECURE:-true}
- FRONTEND_ORIGIN=${FRONTEND_ORIGIN:-} - FRONTEND_ORIGIN=${FRONTEND_ORIGIN:-}
- SITE_MODE=${SITE_MODE:-atelier}
- RUST_LOG=${RUST_LOG:-info} - RUST_LOG=${RUST_LOG:-info}
restart: unless-stopped restart: unless-stopped
networks: networks:
- atelier_net - internal_net
healthcheck: healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"] test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"]
interval: 15s interval: 15s
@@ -34,20 +35,21 @@ services:
max-file: "3" max-file: "3"
frontend: frontend:
container_name: elas-atelier-frontend container_name: narlblog-frontend
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "4322:4321" - "4321"
environment: environment:
- PUBLIC_API_URL=http://backend:3000 - PUBLIC_API_URL=${PUBLIC_API_URL:-}
- SITE_MODE=${SITE_MODE:-atelier}
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
networks: networks:
- atelier_net - internal_net
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -55,6 +57,6 @@ services:
max-file: "3" max-file: "3"
networks: networks:
atelier_net: internal_net:
name: elas-atelier-net name: narlblog-net
driver: bridge driver: bridge
Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 17 KiB

+121 -2
View File
@@ -18,14 +18,35 @@
<i class="cs-fx-corner cs-fx-corner--tr"></i> <i class="cs-fx-corner cs-fx-corner--tr"></i>
<i class="cs-fx-corner cs-fx-corner--bl"></i> <i class="cs-fx-corner cs-fx-corner--bl"></i>
<i class="cs-fx-corner cs-fx-corner--br"></i> <i class="cs-fx-corner cs-fx-corner--br"></i>
<div class="cs-hud">
<div class="cs-hud-item cs-hud--tl">0x00 // ADDR</div>
<div class="cs-hud-item cs-hud--tr">SYS.PTR // 0.0, 0.0</div>
<div class="cs-hud-item cs-hud--bl">MEM.DUMP // OK</div>
<div class="cs-hud-item cs-hud--br">ATELIER.V6 // #0F2A</div>
</div>
<div class="cs-boot">
<div class="cs-boot-log">
<p>> INITIALIZING ARCHIVE...</p>
<p>> LOADING CYBERSIGIL.SYS... [ OK ]</p>
<p>> ALLOCATING BUFFER 0x8F2A... [ OK ]</p>
<p>> DECRYPTING BIOMETRICS... [ OK ]</p>
<p>> ACCESS GRANTED.</p>
</div>
</div>
</div> </div>
<script> <script>
import { buildCybersigil } from '../lib/cybersigil'; import { buildCybersigil } from '../lib/cybersigil';
let teardown: (() => void) | null = null;
function initCyberFx() { function initCyberFx() {
const root = document.documentElement; const root = document.documentElement;
if (!root.classList.contains('cybersigil')) return; if (!root.classList.contains('cybersigil')) return;
const fx = document.querySelector<HTMLElement>('.cs-fx');
if (!fx) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -42,10 +63,11 @@
}); });
} }
/* ─── One slow-spinning sigil filling the background ─── */ /* ─── One sigil filling the background. count:6 (was 9) — fewer branch
* nodes ⇒ far fewer perpetually-animating strokes, same silhouette. ─── */
const wire = document.querySelector<HTMLElement>('.cs-fx-wire'); const wire = document.querySelector<HTMLElement>('.cs-fx-wire');
if (wire && !wire.classList.contains('cs-fx-wire--sig')) { if (wire && !wire.classList.contains('cs-fx-wire--sig')) {
wire.innerHTML = buildCybersigil({ count: 9 }); wire.innerHTML = buildCybersigil({ count: 6 });
wire.classList.add('cs-fx-wire--sig'); wire.classList.add('cs-fx-wire--sig');
} }
@@ -72,6 +94,103 @@
targets.forEach((t) => io.observe(t)); targets.forEach((t) => io.observe(t));
} }
} }
/* ─── Ambient: entrance fade-in + scroll recede + parallax ─── */
if (teardown) teardown();
const off: Array<() => void> = [];
let depth = 0, raf = 0;
let mx = 0, my = 0; // mouse relative (-1..1)
const hudTL = fx.querySelector('.cs-hud--tl');
const hudTR = fx.querySelector('.cs-hud--tr');
const hudBL = fx.querySelector('.cs-hud--bl');
const apply = () => {
raf = 0;
fx.style.opacity = String(1 - 0.5 * depth);
// subtle parallax drift
root.style.setProperty('--cs-px', `${(mx * 15).toFixed(1)}px`);
root.style.setProperty('--cs-py', `${(my * 15).toFixed(1)}px`);
root.style.setProperty('--cs-cx', `${(mx * -8).toFixed(1)}px`);
root.style.setProperty('--cs-cy', `${(my * -8).toFixed(1)}px`);
// update HUD
if (hudTL) hudTL.textContent = `0x${Math.floor(depth * 255).toString(16).toUpperCase().padStart(2, '0')} // ADDR`;
if (hudTR) hudTR.textContent = `SYS.PTR // ${mx.toFixed(2)}, ${my.toFixed(2)}`;
};
/* ─── Terminal Command Echo ─── */
const onBtnClick = (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest('button, a.btn');
if (!btn || !hudBL) return;
const label = btn.textContent?.trim().slice(0, 12).toUpperCase() || 'NULL';
hudBL.textContent = `> CMD: [${label}] ... [OK]`;
hudBL.classList.remove('cs-hud-flicker');
void hudBL.offsetWidth;
hudBL.classList.add('cs-hud-flicker');
};
window.addEventListener('click', onBtnClick);
off.push(() => window.removeEventListener('click', onBtnClick));
/* ─── Character Scramble ─── */
const scrambleChars = '!@#$%^&*()_+{}:"<>?-=[];\',./';
const onHover = (e: MouseEvent) => {
const el = (e.target as HTMLElement).closest('.font-display, .btn, .prose h1, .prose h2');
if (!el || el.classList.contains('cs-is-scrambling')) return;
const original = el.textContent || '';
if (!original.trim()) return;
el.classList.add('cs-is-scrambling');
let iterations = 0;
const interval = setInterval(() => {
el.textContent = original.split('').map((char, index) => {
if (index < iterations) return original[index];
return scrambleChars[Math.floor(Math.random() * scrambleChars.length)];
}).join('');
if (iterations >= original.length) {
clearInterval(interval);
el.classList.remove('cs-is-scrambling');
}
iterations += 1 / 3;
}, 30);
};
window.addEventListener('mouseover', onHover);
off.push(() => window.removeEventListener('mouseover', onHover));
const schedule = () => { if (!raf) raf = requestAnimationFrame(apply); };
const onScroll = () => {
const vh = window.innerHeight || 1;
depth = Math.max(0, Math.min(1, window.scrollY / vh));
schedule();
};
const onMouseMove = (e: MouseEvent) => {
mx = (e.clientX / window.innerWidth) * 2 - 1;
my = (e.clientY / window.innerHeight) * 2 - 1;
schedule();
};
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
window.addEventListener('mousemove', onMouseMove, { passive: true });
off.push(() => window.removeEventListener('scroll', onScroll));
off.push(() => window.removeEventListener('resize', onScroll));
off.push(() => window.removeEventListener('mousemove', onMouseMove));
onScroll();
/* Freeze every loop while the tab is hidden — idle-battery win. */
const onVis = () => fx.classList.toggle('is-paused', document.hidden);
document.addEventListener('visibilitychange', onVis);
off.push(() => document.removeEventListener('visibilitychange', onVis));
onVis();
teardown = () => {
if (raf) cancelAnimationFrame(raf);
off.forEach((fn) => fn());
teardown = null;
};
} }
initCyberFx(); initCyberFx();
@@ -1,21 +1,24 @@
import { useState } from 'react'; import { useState } from 'react';
import { deletePost } from '../../lib/api'; import { deletePost } from '../../lib/api';
import { confirmDialog, notify } from '../../lib/confirm'; import { confirmDialog, notify } from '../../lib/confirm';
import { type SiteMode, copy } from '../../lib/siteMode';
interface Props { interface Props {
slug: string; slug: string;
title: string; title: string;
variant?: 'icon' | 'full'; variant?: 'icon' | 'full';
mode?: SiteMode;
} }
export default function DeletePostButton({ slug, title, variant = 'full' }: Props) { export default function DeletePostButton({ slug, title, variant = 'full', mode = 'atelier' }: Props) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const c = copy(mode);
async function handleClick() { async function handleClick() {
if (busy) return; if (busy) return;
const ok = await confirmDialog({ const ok = await confirmDialog({
title: 'Delete this work?', title: c.deletePostTitle,
message: `${title}” will be permanently removed. This cannot be undone.`, message: c.deletePostMsg(title),
confirmLabel: 'Delete', confirmLabel: 'Delete',
cancelLabel: 'Cancel', cancelLabel: 'Cancel',
}); });
+293 -59
View File
@@ -1,11 +1,16 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
// useLayoutEffect warns when React renders this island on the server; the
// measurement only matters on the client anyway.
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
import { deletePost } from '../../lib/api'; import { deletePost } from '../../lib/api';
import { confirmDialog, notify } from '../../lib/confirm'; import { confirmDialog, notify } from '../../lib/confirm';
import { buildCybersigil } from '../../lib/cybersigil'; import { buildCybersigil } from '../../lib/cybersigil';
import { type SiteMode, copy } from '../../lib/siteMode';
// Per-plate sigil accent. Built post-mount (not during render) so the random // Per-plate sigil accent. Built post-mount (not during render) so the random
// markup never differs between SSR and hydration. Inert/display:none off the // markup never differs between SSR and hydration. Inert/display:none off the
// cybersigil theme; carves in on plate hover/focus via global.css. // cybersigil theme; carves over the image on plate hover/focus via global.css.
function PlateSigil() { function PlateSigil() {
const [html, setHtml] = useState(''); const [html, setHtml] = useState('');
useEffect(() => { setHtml(buildCybersigil()); }, []); useEffect(() => { setHtml(buildCybersigil()); }, []);
@@ -44,6 +49,7 @@ interface Post {
interface Props { interface Props {
posts: Post[]; posts: Post[];
isAdmin?: boolean; isAdmin?: boolean;
mode?: SiteMode;
} }
function formatSlug(slug: string) { function formatSlug(slug: string) {
@@ -62,33 +68,108 @@ function formatMonth(date: string) {
return new Date(date).toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); return new Date(date).toLocaleDateString('en-US', { month: 'short' }).toUpperCase();
} }
// Deterministic salon-hang layout. Each tile gets a col-span (out of 12) and an aspect ratio. // Blog rows want a human date, not the gallery's terse MONTH / YEAR split.
// The cycle is chosen so the room reads asymmetric but balanced. function formatLongDate(date: string) {
const LAYOUT_CYCLE: Array<{ col: number; aspect: string; tilt: number }> = [ return new Date(date).toLocaleDateString('en-US', {
{ col: 7, aspect: '4 / 3', tilt: -0.4 }, day: 'numeric',
{ col: 5, aspect: '3 / 4', tilt: 0.3 }, month: 'long',
{ col: 4, aspect: '4 / 5', tilt: -0.2 }, year: 'numeric',
{ 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) { // ── Justified-gallery layout ────────────────────────────────────────────────
// Every cover keeps its true aspect ratio (no crop). Tiles are packed left to
// right; once a tentative row is at least as wide as the container it is
// finalized and its height solved so the row fills the width exactly. The
// trailing partial row stays at the target height (left-aligned), only shrunk
// if a lone wide image would overflow. Result: variable widths, one shared
// image height per visual row, no wasted space.
const GAP = 18; // horizontal gap between tiles in a row (px)
const ROW_GAP = 40; // vertical gap between rows (px)
const MIN_H = 150; // floor so a wide-only row never collapses
// Covers with no usable dimensions (or no cover at all) cycle through a set of
// pleasant ratios so the placeholder tiles still read as an arranged hang.
const FALLBACK_RATIOS = [1.5, 0.78, 1, 1.33, 0.8, 1.6, 0.9];
function aspectOf(post: Post, idx: number): number {
const ci = post.cover_image;
if (ci && ci.w && ci.h && ci.w > 0 && ci.h > 0) {
const r = ci.w / ci.h;
if (Number.isFinite(r) && r > 0) return r;
}
return FALLBACK_RATIOS[idx % FALLBACK_RATIOS.length];
}
function targetRowHeight(cw: number): number {
if (cw < 560) return Math.round(cw * 0.72);
if (cw < 900) return 260;
if (cw < 1280) return 300;
return 340;
}
interface Cell { post: Post; idx: number; aspect: number; w: number; h: number }
function buildRows(
tiles: Array<{ post: Post; idx: number; aspect: number }>,
cw: number,
targetH: number,
chromeX: number,
): Cell[][] {
const rows: Cell[][] = [];
let cur: typeof tiles = [];
let aspSum = 0;
const finalize = (items: typeof tiles, h: number) => {
const rh = Math.round(Math.max(MIN_H, h));
rows.push(
items.map(t => ({ ...t, w: Math.round(t.aspect * rh), h: rh })),
);
};
for (const t of tiles) {
cur = [...cur, t];
aspSum += t.aspect;
const k = cur.length;
const projected = targetH * aspSum + k * chromeX + (k - 1) * GAP;
if (projected >= cw) {
const avail = cw - k * chromeX - (k - 1) * GAP;
const h = Math.min(avail / aspSum, targetH * 1.5);
finalize(cur, h);
cur = [];
aspSum = 0;
}
}
if (cur.length) {
const k = cur.length;
const projected = targetH * aspSum + k * chromeX + (k - 1) * GAP;
let h = targetH;
if (projected > cw) h = (cw - k * chromeX - (k - 1) * GAP) / aspSum;
finalize(cur, Math.min(h, targetH * 1.5));
}
return rows;
}
export default function PostList({ posts: initialPosts, isAdmin = false, mode = 'atelier' }: Props) {
const isBlog = mode === 'blog';
const c = copy(mode);
const [posts, setPosts] = useState(initialPosts); const [posts, setPosts] = useState(initialPosts);
const [deleting, setDeleting] = useState<string | null>(null); const [deleting, setDeleting] = useState<string | null>(null);
const [visible, setVisible] = useState(() => Math.min(PAGE_SIZE, initialPosts.length)); const [visible, setVisible] = useState(() => Math.min(PAGE_SIZE, initialPosts.length));
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// null until measured on the client — keeps SSR and first hydration render
// identical (CSS-only fallback), then the precise layout swaps in.
const [rows, setRows] = useState<Cell[][] | null>(null);
const [chrome, setChrome] = useState(30); // plate mat+border width (px)
async function handleDelete(slug: string, title: string) { async function handleDelete(slug: string, title: string) {
if (deleting) return; if (deleting) return;
const ok = await confirmDialog({ const ok = await confirmDialog({
title: 'Take this off the wall?', title: c.deleteListTitle,
message: `${title}” will be removed from the catalogue. This cannot be undone.`, message: c.deleteListMsg(title),
confirmLabel: 'Remove', confirmLabel: c.deleteListConfirm,
cancelLabel: 'Keep', cancelLabel: c.deleteListCancel,
}); });
if (!ok) return; if (!ok) return;
setDeleting(slug); setDeleting(slug);
@@ -96,7 +177,7 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
await deletePost(slug); await deletePost(slug);
setPosts(p => p.filter(x => x.slug !== slug)); setPosts(p => p.filter(x => x.slug !== slug));
} catch (e) { } catch (e) {
notify(`Failed to remove: ${e instanceof Error ? e.message : 'unknown error'}`); notify(`Failed to delete: ${e instanceof Error ? e.message : 'unknown error'}`);
} finally { } finally {
setDeleting(null); setDeleting(null);
} }
@@ -122,41 +203,86 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
return () => io.disconnect(); return () => io.disconnect();
}, [visible, posts.length]); }, [visible, posts.length]);
if (posts.length === 0) {
return null;
}
const shown = posts.slice(0, visible); const shown = posts.slice(0, visible);
const hasMore = visible < posts.length; const hasMore = visible < posts.length;
return ( // Measure the container + plate chrome and (re)compute the justified rows.
<> // Re-runs on resize and whenever the shown set changes (infinite scroll).
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-x-10 md:gap-y-16"> useIsoLayoutEffect(() => {
{shown.map((post, idx) => { if (isBlog) return; // blog mode renders a plain stack — no justified math
const container = containerRef.current;
if (!container) return;
let frame = 0;
const measure = () => {
const cw = container.clientWidth;
if (cw <= 0) return;
// Mat + border are fixed px regardless of tile size — read once from a
// live plate so the row-fill math is exact for whatever theme is on.
let chromeX = 30;
const plate = container.querySelector<HTMLElement>('.plate');
if (plate) {
const cs = getComputedStyle(plate);
chromeX =
parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) +
parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth);
if (!Number.isFinite(chromeX)) chromeX = 30;
}
setChrome(chromeX);
const tiles = shown.map((post, idx) => ({ post, idx, aspect: aspectOf(post, idx) }));
setRows(buildRows(tiles, cw, targetRowHeight(cw), chromeX));
};
const schedule = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
};
measure();
const ro = new ResizeObserver(schedule);
ro.observe(container);
return () => {
cancelAnimationFrame(frame);
ro.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, posts]);
if (posts.length === 0) return null;
let cellIdx = 0; // running stagger index across all rows
const renderCell = (cell: Cell | null, post: Post, idx: number) => {
const displayTitle = post.title || formatSlug(post.slug); const displayTitle = post.title || formatSlug(post.slug);
const isDeleting = deleting === post.slug; const isDeleting = deleting === post.slug;
const layout = LAYOUT_CYCLE[idx % LAYOUT_CYCLE.length];
const hasCover = !!post.cover_image?.url; const hasCover = !!post.cover_image?.url;
const stagger = Math.min(cellIdx++ * 70, 480);
// Precise layout: tile sized to outer (image + mat) width with flex-grow so
// sub-pixel rounding redistributes and the row fills the width exactly —
// no crop, no wrap. Fallback (pre-measure / no JS): grow proportional to
// aspect so it still reads as a justified hang.
const aspect = cell ? cell.aspect : aspectOf(post, idx);
const ow = cell ? cell.w + chrome : 0;
const articleStyle: React.CSSProperties = cell
? { flex: `${ow} ${ow} ${ow}px`, minWidth: 0, animationDelay: `${stagger}ms` }
: { flex: `${aspect.toFixed(4)} 1 ${Math.round(aspect * 220)}px`, animationDelay: `${stagger}ms` };
const imageStyle: React.CSSProperties = cell
? { height: `${cell.h}px` }
: { aspectRatio: `${aspect}` };
return ( return (
<article <article
key={post.slug} key={post.slug}
className={`relative plate-enter md-col-span ${isDeleting ? 'opacity-40 pointer-events-none' : ''}`} className={`relative plate-enter ${isDeleting ? 'opacity-40 pointer-events-none' : ''}`}
style={{ style={articleStyle}
animationDelay: `${Math.min(idx * 80, 480)}ms`,
['--col-span' as any]: layout.col,
}}
> >
<a <a
href={`/posts/${encodeURIComponent(post.slug)}`} href={`/posts/${encodeURIComponent(post.slug)}`}
className="block plate group" className="block plate group"
style={{ transform: `rotate(${layout.tilt}deg)` }}
aria-label={`View ${displayTitle}`} aria-label={`View ${displayTitle}`}
> >
<div <div className="plate-image" style={imageStyle}>
className={`plate-image ${hasCover ? 'is-natural' : ''}`}
style={hasCover ? undefined : { aspectRatio: layout.aspect }}
>
{hasCover ? ( {hasCover ? (
<img <img
src={post.cover_image!.url} src={post.cover_image!.url}
@@ -170,25 +296,18 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
) : ( ) : (
<div <div
className="w-full h-full flex items-center justify-center text-[var(--rosewater)]" className="w-full h-full flex items-center justify-center text-[var(--rosewater)]"
style={{ style={{ background: `linear-gradient(135deg, var(--mauve), var(--mantle))` }}
background: `linear-gradient(135deg, var(--mauve), var(--mantle))`,
}}
> >
<span className="font-display italic text-3xl opacity-70"> <span className="font-display italic text-3xl opacity-70">untitled</span>
untitled
</span>
</div> </div>
)} )}
{post.image_count > 1 && ( {post.image_count > 1 && (
<span className="plate-tag-mini"> <span className="plate-tag-mini">{post.image_count} plates</span>
{post.image_count} plates
</span>
)} )}
{post.draft && ( {post.draft && (
<span className="plate-tag-mini plate-tag-mini--draft"> <span className="plate-tag-mini plate-tag-mini--draft">Sketch</span>
Sketch
</span>
)} )}
<PlateSigil />
</div> </div>
<div className="plate-caption"> <div className="plate-caption">
@@ -207,9 +326,7 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
{post.tags && post.tags.length > 0 && ( {post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3 px-1"> <div className="flex flex-wrap gap-1.5 mt-3 px-1">
{post.tags.slice(0, 4).map(tag => ( {post.tags.slice(0, 4).map(tag => (
<span key={tag} className="chip"> <span key={tag} className="chip">{tag}</span>
{tag}
</span>
))} ))}
</div> </div>
)} )}
@@ -237,11 +354,96 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
</button> </button>
</div> </div>
)} )}
<PlateSigil />
</article> </article>
); );
})} };
// ── Blog mode: one writing-first row per post ──────────────────────────
const renderRow = (post: Post, idx: number) => {
const displayTitle = post.title || formatSlug(post.slug);
const isDeleting = deleting === post.slug;
const hasCover = !!post.cover_image?.url;
const blurb = post.summary || post.excerpt;
const stagger = Math.min(idx * 55, 420);
return (
<article
key={post.slug}
className={`post-row plate-enter ${isDeleting ? 'opacity-40 pointer-events-none' : ''}`}
style={{ animationDelay: `${stagger}ms` }}
>
<a
href={`/posts/${encodeURIComponent(post.slug)}`}
className="post-row-link group"
aria-label={`Read ${displayTitle}`}
>
<div className="post-row-body">
<h2 className="post-row-title">{displayTitle}</h2>
<div className="post-row-meta">
<span>{formatLongDate(post.date)}</span>
<span className="sep" aria-hidden="true">·</span>
<span>{post.reading_time} min read</span>
{post.draft && (
<span className="chip chip-draft post-row-draft">{c.draftShort}</span>
)}
</div>
{blurb && <p className="post-row-excerpt">{blurb}</p>}
{post.tags && post.tags.length > 0 && (
<div className="post-row-tags">
{post.tags.slice(0, 4).map(tag => (
<span key={tag} className="chip">{tag}</span>
))}
</div>
)}
</div>
{hasCover && (
<div className="post-row-thumb">
<img
src={post.cover_image!.url}
alt={post.cover_image!.alt || displayTitle}
width={post.cover_image!.w}
height={post.cover_image!.h}
loading={idx < 4 ? 'eager' : 'lazy'}
decoding="async"
/>
<PlateSigil />
</div>
)}
</a>
{isAdmin && (
<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"
aria-label={`Edit ${displayTitle}`}
className="btn btn--ghost btn--icon btn--sm"
>
<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"
aria-label={`Delete ${displayTitle}`}
className="btn btn--danger btn--icon btn--sm"
>
<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>
)}
</article>
);
};
if (isBlog) {
return (
<>
<div className="post-list">
{shown.map((post, idx) => renderRow(post, idx))}
</div> </div>
{hasMore && ( {hasMore && (
<div <div
@@ -249,7 +451,39 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props
className="mt-12 md:mt-16 flex items-center justify-center text-[var(--subtext0)] font-display italic text-sm tracking-[0.2em] uppercase" className="mt-12 md:mt-16 flex items-center justify-center text-[var(--subtext0)] font-display italic text-sm tracking-[0.2em] uppercase"
aria-hidden="true" aria-hidden="true"
> >
<span className="opacity-60">arranging more</span> <span className="opacity-60">{c.loadingMore}</span>
</div>
)}
</>
);
}
return (
<>
<div ref={containerRef} className="just-gallery">
{rows
? rows.map((row, r) => (
<div
className="just-row"
key={r}
style={{ marginBottom: r === rows.length - 1 ? 0 : ROW_GAP }}
>
{row.map(cell => renderCell(cell, cell.post, cell.idx))}
</div>
))
: (
<div className="just-row just-row--fallback">
{shown.map((post, idx) => renderCell(null, post, idx))}
</div>
)}
</div>
{hasMore && (
<div
ref={sentinelRef}
className="mt-12 md:mt-16 flex items-center justify-center text-[var(--subtext0)] font-display italic text-sm tracking-[0.2em] uppercase"
aria-hidden="true"
>
<span className="opacity-60">{c.loadingMore}</span>
</div> </div>
)} )}
</> </>
+9 -7
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { getPosts } from '../../lib/api'; import { getPosts } from '../../lib/api';
import { type SiteMode, copy } from '../../lib/siteMode';
interface Post { interface Post {
slug: string; slug: string;
@@ -27,7 +28,8 @@ function formatDate(date: string) {
}); });
} }
export default function Search() { export default function Search({ mode = 'atelier' }: { mode?: SiteMode }) {
const c = copy(mode);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [posts, setPosts] = useState<Post[] | null>(null); const [posts, setPosts] = useState<Post[] | null>(null);
@@ -128,7 +130,7 @@ export default function Search() {
<button <button
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label={`Search the catalogue (${isMac ? '⌘' : 'Ctrl'}+K)`} aria-label={`${c.searchAria} (${isMac ? '⌘' : 'Ctrl'}+K)`}
className="topbar-control tc-collapse-md kbd-tip-host" className="topbar-control tc-collapse-md kbd-tip-host"
> >
<svg <svg
@@ -156,7 +158,7 @@ export default function Search() {
className="search-overlay fixed inset-0 z-[200] flex items-start justify-center pt-[10vh] md:pt-[15vh] px-4" className="search-overlay fixed inset-0 z-[200] flex items-start justify-center pt-[10vh] md:pt-[15vh] px-4"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Search the catalogue" aria-label={c.searchAria}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
<div <div
@@ -194,7 +196,7 @@ export default function Search() {
value={query} value={query}
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
onKeyDown={onInputKey} onKeyDown={onInputKey}
placeholder="Search the catalogue…" placeholder={`${c.searchPlaceholder}`}
aria-label="Search query" aria-label="Search query"
className="search-input flex-1 bg-transparent outline-none text-base text-[var(--text)] placeholder:text-[var(--subtext0)] font-display italic" className="search-input flex-1 bg-transparent outline-none text-base text-[var(--text)] placeholder:text-[var(--subtext0)] font-display italic"
/> />
@@ -205,14 +207,14 @@ export default function Search() {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{loading && ( {loading && (
<div className="px-4 py-10 text-center font-display italic text-[var(--subtext0)]">Fetching the catalogue</div> <div className="px-4 py-10 text-center font-display italic text-[var(--subtext0)]">{c.searchFetching}</div>
)} )}
{error && ( {error && (
<div className="px-4 py-8 text-center text-sm text-[var(--red)] font-display italic">{error}</div> <div className="px-4 py-8 text-center text-sm text-[var(--red)] font-display italic">{error}</div>
)} )}
{!loading && !error && posts && results.length === 0 && ( {!loading && !error && posts && results.length === 0 && (
<div className="px-4 py-10 text-center font-display italic text-[var(--subtext0)]"> <div className="px-4 py-10 text-center font-display italic text-[var(--subtext0)]">
{query ? 'No works match.' : 'The catalogue is empty.'} {query ? c.searchNoMatch : c.searchEmpty}
</div> </div>
)} )}
{!loading && !error && results.length > 0 && ( {!loading && !error && results.length > 0 && (
@@ -236,7 +238,7 @@ export default function Search() {
<span className="truncate">{title}</span> <span className="truncate">{title}</span>
{p.draft && ( {p.draft && (
<span className="chip chip-draft shrink-0 !text-[0.62rem] !py-0"> <span className="chip chip-draft shrink-0 !text-[0.62rem] !py-0">
Sketch {c.draftShort}
</span> </span>
)} )}
</div> </div>
+15 -11
View File
@@ -13,14 +13,17 @@ import { useLivePreview } from './editor/useLivePreview';
import { useImageUpload } from './editor/useImageUpload'; import { useImageUpload } from './editor/useImageUpload';
import { useAssetAutocomplete } from './editor/useAssetAutocomplete'; import { useAssetAutocomplete } from './editor/useAssetAutocomplete';
import { usePostMeta } from './editor/usePostMeta'; import { usePostMeta } from './editor/usePostMeta';
import { type SiteMode, copy } from '../../../lib/siteMode';
const AssetManager = lazy(() => import('./AssetManager')); const AssetManager = lazy(() => import('./AssetManager'));
interface Props { interface Props {
editSlug?: string; editSlug?: string;
mode?: SiteMode;
} }
export default function Editor({ editSlug }: Props) { export default function Editor({ editSlug, mode = 'atelier' }: Props) {
const c = copy(mode);
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null); const viewRef = useRef<EditorView | null>(null);
const [vimEnabled, setVimEnabled] = useState(false); const [vimEnabled, setVimEnabled] = useState(false);
@@ -41,7 +44,7 @@ export default function Editor({ editSlug }: Props) {
editorRef, editorRef,
getCachedAssets: assetCache.getCachedAssets, getCachedAssets: assetCache.getCachedAssets,
}); });
const meta = usePostMeta({ editSlug, getContent, setContent }); const meta = usePostMeta({ editSlug, getContent, setContent, mode });
const { const {
title, setTitle, slug, setSlug, setSlugTouched, date, setDate, title, setTitle, slug, setSlug, setSlugTouched, date, setDate,
@@ -215,7 +218,7 @@ export default function Editor({ editSlug }: Props) {
value={title} value={title}
onChange={e => setTitle(e.target.value)} onChange={e => setTitle(e.target.value)}
required required
placeholder="Untitled (charcoal on paper)" placeholder={c.editorTitlePh}
className="field-input" className="field-input"
/> />
</div> </div>
@@ -240,7 +243,7 @@ export default function Editor({ editSlug }: Props) {
value={slug} value={slug}
onChange={e => { setSlug(e.target.value); setSlugTouched(true); }} onChange={e => { setSlug(e.target.value); setSlugTouched(true); }}
required required
placeholder="untitled-charcoal-on-paper" placeholder={c.editorSlugPh}
className="field-input font-mono" className="field-input font-mono"
/> />
</div> </div>
@@ -253,7 +256,7 @@ export default function Editor({ editSlug }: Props) {
type="text" type="text"
value={tagsInput} value={tagsInput}
onChange={e => setTagsInput(e.target.value)} onChange={e => setTagsInput(e.target.value)}
placeholder="oil, paper, 2026, study" placeholder={c.editorTagsPh}
className="field-input" className="field-input"
/> />
</div> </div>
@@ -264,18 +267,18 @@ export default function Editor({ editSlug }: Props) {
onChange={e => setDraft(e.target.checked)} onChange={e => setDraft(e.target.checked)}
className="accent-[var(--peach)]" className="accent-[var(--peach)]"
/> />
<span className="text-sm font-display italic text-[var(--subtext1)]">Sketch (draft)</span> <span className="text-sm font-display italic text-[var(--subtext1)]">{c.editorDraftLabel}</span>
</label> </label>
</div> </div>
{/* Summary */} {/* Summary */}
<div> <div>
<label className="field-label">Caption (optional)</label> <label className="field-label">{c.editorSummaryLabel}</label>
<textarea <textarea
value={summary} value={summary}
onChange={e => setSummary(e.target.value)} onChange={e => setSummary(e.target.value)}
rows={2} rows={2}
placeholder="A short caption for the catalogue index..." placeholder={c.editorSummaryPh}
className="field-input resize-none" className="field-input resize-none"
/> />
</div> </div>
@@ -291,10 +294,11 @@ export default function Editor({ editSlug }: Props) {
<button <button
type="button" type="button"
onClick={() => setVimEnabled(v => !v)} onClick={() => setVimEnabled(v => !v)}
className={`btn btn--ghost btn--sm${vimEnabled ? ' is-active' : ''}`} className={`btn btn--ghost btn--sm vim-toggle${vimEnabled ? ' is-active' : ''}`}
title={vimEnabled ? 'Vim mode ON' : 'Vim mode OFF'} aria-pressed={vimEnabled}
title={vimEnabled ? 'Vim mode ON — click to disable' : 'Vim mode OFF — click to enable'}
> >
{vimEnabled ? 'VIM' : 'vim'} {vimEnabled ? 'Vim on' : 'Vim off'}
</button> </button>
<button <button
type="button" type="button"
@@ -1,8 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { login, ApiError } from '../../../lib/api'; import { login, ApiError } from '../../../lib/api';
import { useAuth } from '../../../stores/auth'; import { useAuth } from '../../../stores/auth';
import { type SiteMode, copy } from '../../../lib/siteMode';
export default function Login() { export default function Login({ mode = 'atelier' }: { mode?: SiteMode }) {
const c = copy(mode);
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -80,7 +82,7 @@ export default function Login() {
<div className="text-center mt-6"> <div className="text-center mt-6">
<a href="/" className="back-link"> <a href="/" className="back-link">
<span className="bl-arrow" aria-hidden="true"></span> <span className="bl-arrow" aria-hidden="true"></span>
Back to the catalogue {c.adminBack}
</a> </a>
</div> </div>
</div> </div>
@@ -1,16 +1,19 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ApiError, deletePost, getPost, savePost } from '../../../../lib/api'; import { ApiError, deletePost, getPost, savePost } from '../../../../lib/api';
import { confirmDialog, notify } from '../../../../lib/confirm'; import { confirmDialog, notify } from '../../../../lib/confirm';
import { type SiteMode, copy } from '../../../../lib/siteMode';
import { clientSlugify } from './codemirror'; import { clientSlugify } from './codemirror';
interface Opts { interface Opts {
editSlug?: string; editSlug?: string;
getContent: () => string; getContent: () => string;
setContent: (s: string) => void; setContent: (s: string) => void;
mode?: SiteMode;
} }
/** Post metadata form + slug derivation + load/save/delete. */ /** Post metadata form + slug derivation + load/save/delete. */
export function usePostMeta({ editSlug, getContent, setContent }: Opts) { export function usePostMeta({ editSlug, getContent, setContent, mode = 'atelier' }: Opts) {
const c = copy(mode);
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [slug, setSlug] = useState(editSlug || ''); const [slug, setSlug] = useState(editSlug || '');
@@ -48,7 +51,7 @@ export function usePostMeta({ editSlug, getContent, setContent }: Opts) {
notify('Title, slug, and body are required.', 'error'); notify('Title, slug, and body are required.', 'error');
return; return;
} }
if (!/!\[[^\]]*\]\([^)]+\)/.test(content)) { if (mode === 'atelier' && !/!\[[^\]]*\]\([^)]+\)/.test(content)) {
notify( notify(
'Add at least one image before saving — drag, paste, or use the Add image button.', 'Add at least one image before saving — drag, paste, or use the Add image button.',
'error', 'error',
@@ -87,9 +90,9 @@ export function usePostMeta({ editSlug, getContent, setContent }: Opts) {
async function handleDelete() { async function handleDelete() {
const target = originalSlug || slug; const target = originalSlug || slug;
const ok = await confirmDialog({ const ok = await confirmDialog({
title: 'Remove from catalogue?', title: c.deletePostTitle,
message: `${target}” will be permanently removed. This cannot be undone.`, message: c.deletePostMsg(target),
confirmLabel: 'Remove', confirmLabel: c.deleteListConfirm,
}); });
if (!ok) return; if (!ok) return;
try { try {
+4 -2
View File
@@ -1,5 +1,6 @@
--- ---
import Layout from './Layout.astro'; import Layout from './Layout.astro';
import { getSiteMode, copy } from '../lib/siteMode';
interface Props { interface Props {
title: string; title: string;
@@ -7,6 +8,7 @@ interface Props {
} }
const { title, wide = false } = Astro.props; const { title, wide = false } = Astro.props;
const c = copy(getSiteMode());
if (Astro.cookies.get('admin_session')?.value !== '1') { if (Astro.cookies.get('admin_session')?.value !== '1') {
return Astro.redirect('/admin/login'); return Astro.redirect('/admin/login');
@@ -19,9 +21,9 @@ if (Astro.cookies.get('admin_session')?.value !== '1') {
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<a href="/" class="back-link mb-3"> <a href="/" class="back-link mb-3">
<span class="bl-arrow" aria-hidden="true">←</span> <span class="bl-arrow" aria-hidden="true">←</span>
Back to the catalogue {c.adminBack}
</a> </a>
<div class="font-display italic text-[var(--mauve)] text-xs tracking-[0.3em] uppercase mb-2">Artist's desk</div> <div class="font-display italic text-[var(--mauve)] text-xs tracking-[0.3em] uppercase mb-2">{c.adminEyebrow}</div>
<h1 class="font-display italic font-semibold text-[var(--text)] text-4xl md:text-5xl tracking-tight leading-[1.05]"> <h1 class="font-display italic font-semibold text-[var(--text)] text-4xl md:text-5xl tracking-tight leading-[1.05]">
{title} {title}
</h1> </h1>
+7 -3
View File
@@ -10,6 +10,7 @@ import CyberFx from '../components/CyberFx.astro';
import Search from '../components/react/Search'; import Search from '../components/react/Search';
import LogoutButton from '../components/react/LogoutButton'; import LogoutButton from '../components/react/LogoutButton';
import EditableText from '../components/react/EditableText'; import EditableText from '../components/react/EditableText';
import { getSiteMode, copy } from '../lib/siteMode';
interface Props { interface Props {
title: string; title: string;
@@ -24,6 +25,9 @@ interface Props {
const { title, wide = false, description, image, type = 'website', minimal = false } = Astro.props; const { title, wide = false, description, image, type = 'website', minimal = false } = Astro.props;
const siteMode = getSiteMode();
const c = copy(siteMode);
const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000'; const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
const isAdmin = Astro.cookies.get('admin_session')?.value === '1'; const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
@@ -60,7 +64,7 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
--- ---
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class={`mode-${siteMode}`}>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -133,7 +137,7 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
{hasContact && ( {hasContact && (
<a href="/contact" class="topbar-control">Contact</a> <a href="/contact" class="topbar-control">Contact</a>
)} )}
<Search client:idle /> <Search client:idle mode={siteMode} />
<span class="topbar-divider" aria-hidden="true"></span> <span class="topbar-divider" aria-hidden="true"></span>
{isAdmin ? ( {isAdmin ? (
<LogoutButton client:idle /> <LogoutButton client:idle />
@@ -162,7 +166,7 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
<> <>
<div class="section-rule mb-6"> <div class="section-rule mb-6">
<span class="ornament">✦</span> <span class="ornament">✦</span>
<span class="font-display italic text-[var(--subtext0)] text-sm">end of catalogue</span> <span class="font-display italic text-[var(--subtext0)] text-sm">{c.footerEnd}</span>
<span class="ornament">✦</span> <span class="ornament">✦</span>
</div> </div>
<p class="font-display italic text-base text-[var(--subtext1)] mb-2"> <p class="font-display italic text-base text-[var(--subtext1)] mb-2">
+174 -65
View File
@@ -29,6 +29,10 @@ export const GLYPHS: readonly Glyph[] = [
{ w: 14, h: 24, d: 'M0 0 L0 24 M0 6 L12 2 M0 14 L13 10 M0 21 L9 19' }, { w: 14, h: 24, d: 'M0 0 L0 24 M0 6 L12 2 M0 14 L13 10 M0 21 L9 19' },
{ w: 20, h: 18, d: 'M0 9 Q10 -2 19 4 M19 4 L15 0 M19 4 L20 9 M0 9 L4 16' }, { w: 20, h: 18, d: 'M0 9 Q10 -2 19 4 M19 4 L15 0 M19 4 L20 9 M0 9 L4 16' },
{ w: 15, h: 20, d: 'M0 0 Q14 6 13 13 Q12 19 0 19 M7 8 Q11 11 11 16' }, { w: 15, h: 20, d: 'M0 0 Q14 6 13 13 Q12 19 0 19 M7 8 Q11 11 11 16' },
// New "occult-tech" variants
{ w: 18, h: 18, d: 'M9 0 L9 18 M0 9 L18 9 M4 4 L14 14 M14 4 L4 14' }, // Star/Cross
{ w: 20, h: 20, d: 'M10 2 A8 8 0 1 0 10 18 A8 8 0 1 0 10 2 M10 6 A4 4 0 1 0 10 14 A4 4 0 1 0 10 6' }, // Eye/Nested
{ w: 12, h: 20, d: 'M0 0 L12 0 M0 8 L12 8 M0 16 L12 16 M6 0 L6 20' }, // Triple-bar
] as const; ] as const;
export interface SigilOptions { export interface SigilOptions {
@@ -61,16 +65,48 @@ export function buildCybersigil(opts: SigilOptions = {}): string {
const ax = Math.abs(x); const ax = Math.abs(x);
if (ax > maxX) maxX = ax; if (ax > maxX) maxX = ax;
}; };
const emit = (d: string, cls: string) => { const emit = (d: string, cls: string, style?: string) => {
if (strokeCount >= MAX_PATHS) return; if (strokeCount >= MAX_PATHS) return;
const s = style ? ` style="${style};--i:${strokeCount % 16}"` : ` style="--i:${strokeCount % 16}"`;
parts.push(
`<path class="${cls}" d="${d}" pathLength="1"${s} filter="url(#cs-erosion)"/>`,
);
strokeCount++;
};
const emitRect = (x: number, y: number, sz: number, cls: string) => {
parts.push(
`<rect x="${n(x)}" y="${n(y)}" width="${n(sz)}" height="${n(sz)}" class="${cls}" style="--i:${strokeCount % 16}"/>`,
);
strokeCount++;
};
const emitHUD = (d: string, cls: string) => {
parts.push( parts.push(
`<path class="${cls}" d="${d}" pathLength="1" style="--i:${strokeCount % 16}"/>`, `<path class="${cls}" d="${d}" pathLength="1" style="--i:${strokeCount % 16}"/>`,
); );
strokeCount++; strokeCount++;
}; };
const emitText = (x: number, y: number, txt: string, cls: string) => {
parts.push(
`<text x="${n(x)}" y="${n(y)}" class="${cls}" style="--i:${strokeCount % 16}">${txt}</text>`,
);
strokeCount++;
};
const microFilaments = (at: Pt, ang: number) => {
const num = 5 + Math.floor(rng() * 5);
for (let i = 0; i < num; i++) {
const a = ang + rnd(-0.5, 0.5);
const l = rnd(2, 8);
const tip: Pt = [at[0] + Math.cos(a) * l, at[1] + Math.sin(a) * l];
emit(`M${n(at[0])} ${n(at[1])} L${n(tip[0])} ${n(tip[1])}`, 'cs-sig-micro');
}
};
// Catmull-Rom → cubic Bézier through an ordered point list (organic sweep). // Catmull-Rom → cubic Bézier through an ordered point list (organic sweep).
const spline = (pts: Pt[]): string => { const spline = (pts: Pt[], tension = 6): string => {
if (pts.length < 2) return ''; if (pts.length < 2) return '';
let d = `M${n(pts[0][0])} ${n(pts[0][1])}`; let d = `M${n(pts[0][0])} ${n(pts[0][1])}`;
for (let i = 0; i < pts.length - 1; i++) { for (let i = 0; i < pts.length - 1; i++) {
@@ -78,8 +114,8 @@ export function buildCybersigil(opts: SigilOptions = {}): string {
const p1 = pts[i]; const p1 = pts[i];
const p2 = pts[i + 1]; const p2 = pts[i + 1];
const p3 = pts[i + 2] ?? p2; const p3 = pts[i + 2] ?? p2;
const c1: Pt = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]; const c1: Pt = [p1[0] + (p2[0] - p0[0]) / tension, p1[1] + (p2[1] - p0[1]) / tension];
const c2: Pt = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]; const c2: Pt = [p2[0] - (p3[0] - p1[0]) / tension, p2[1] - (p3[1] - p1[1]) / tension];
d += `C${n(c1[0])} ${n(c1[1])} ${n(c2[0])} ${n(c2[1])} ${n(p2[0])} ${n(p2[1])}`; d += `C${n(c1[0])} ${n(c1[1])} ${n(c2[0])} ${n(c2[1])} ${n(p2[0])} ${n(p2[1])}`;
track(c1[0]); track(c1[0]);
track(c2[0]); track(c2[0]);
@@ -94,10 +130,11 @@ export function buildCybersigil(opts: SigilOptions = {}): string {
if (tip[0] < -3) tip[0] = -3; if (tip[0] < -3) tip[0] = -3;
track(base[0]); track(base[0]);
track(tip[0]); track(tip[0]);
if (rng() < 0.45) { if (rng() < 0.55) {
const k = rng() < 0.3 ? 0.6 : 0.3; // varying kink intensity
const mid: Pt = [ const mid: Pt = [
(base[0] + tip[0]) / 2 + Math.cos(ang + Math.PI / 2) * len * 0.3, (base[0] + tip[0]) / 2 + Math.cos(ang + Math.PI / 2) * len * k,
(base[1] + tip[1]) / 2 + Math.sin(ang + Math.PI / 2) * len * 0.3, (base[1] + tip[1]) / 2 + Math.sin(ang + Math.PI / 2) * len * k,
]; ];
emit( emit(
`M${n(base[0])} ${n(base[1])}Q${n(mid[0])} ${n(mid[1])} ${n(tip[0])} ${n(tip[1])}`, `M${n(base[0])} ${n(base[1])}Q${n(mid[0])} ${n(mid[1])} ${n(tip[0])} ${n(tip[1])}`,
@@ -108,10 +145,10 @@ export function buildCybersigil(opts: SigilOptions = {}): string {
} }
}; };
const ornament = (at: Pt, ang: number) => { const ornament = (at: Pt, ang: number, scaleFactor = 1) => {
const g = pick(GLYPHS); const g = pick(GLYPHS);
const s = rnd(0.45, 0.85); const s = rnd(0.4, 0.75) * scaleFactor;
const deg = (ang * 180) / Math.PI + rnd(-25, 25); const deg = (ang * 180) / Math.PI + rnd(-20, 20);
track(at[0] + g.w * s); track(at[0] + g.w * s);
parts.push( parts.push(
`<g transform="translate(${n(at[0])} ${n(at[1])}) rotate(${n(deg)}) scale(${n(s)})">` + `<g transform="translate(${n(at[0])} ${n(at[1])}) rotate(${n(deg)}) scale(${n(s)})">` +
@@ -121,46 +158,66 @@ export function buildCybersigil(opts: SigilOptions = {}): string {
}; };
// Recursive limb: a curved sweep out from (ox,oy) along `ang`, hooking back, // Recursive limb: a curved sweep out from (ox,oy) along `ang`, hooking back,
// scattering barbs, shedding a filament shadow, branching, sometimes tipped // scattering barbs, shedding multiple filament shadows, branching.
// with a motif.
const limb = (ox: number, oy: number, ang: number, scale: number, depth: number) => { const limb = (ox: number, oy: number, ang: number, scale: number, depth: number) => {
if (strokeCount >= MAX_PATHS) return; if (strokeCount >= MAX_PATHS) return;
const L = scale * rnd(34, 64); const L = scale * rnd(38, 72);
const dx = Math.cos(ang) * L; const dx = Math.cos(ang) * L;
const dy = Math.sin(ang) * L; const dy = Math.sin(ang) * L;
const peak: Pt = [ox + dx, oy + dy]; const peak: Pt = [ox + dx, oy + dy];
const mid: Pt = [ const mid: Pt = [
ox + dx * 0.45 + Math.cos(ang + Math.PI / 2) * rnd(-10, 14), ox + dx * 0.45 + Math.cos(ang + Math.PI / 2) * rnd(-12, 16),
oy + dy * 0.45 + Math.sin(ang + Math.PI / 2) * rnd(-10, 14), oy + dy * 0.45 + Math.sin(ang + Math.PI / 2) * rnd(-12, 16),
]; ];
// hook back toward the spine // hook back toward the spine with more tension
const hook: Pt = [ const hook: Pt = [
peak[0] - Math.cos(ang) * L * rnd(0.3, 0.55), peak[0] - Math.cos(ang) * L * rnd(0.35, 0.6),
peak[1] + Math.sin(ang + 0.7) * L * rnd(0.25, 0.5), peak[1] + Math.sin(ang + 0.8) * L * rnd(0.3, 0.55),
]; ];
const tail: Pt = [Math.max(-2, hook[0] - L * rnd(0.2, 0.4)), hook[1] + rnd(-6, 10)]; const tail: Pt = [Math.max(-2, hook[0] - L * rnd(0.25, 0.45)), hook[1] + rnd(-4, 12)];
const pts: Pt[] = [[ox, oy], mid, peak, hook, tail]; const pts: Pt[] = [[ox, oy], mid, peak, hook, tail];
emit(spline(pts), 'cs-sig-main');
// Depth shading: background limbs are thinner and fainter
const sw = depth === 0 ? 1.6 : 2.6;
const op = depth === 0 ? 0.45 : 0.9;
emit(spline(pts, 5.5), 'cs-sig-main', `stroke-width:${sw};opacity:${op}`);
// Bifurcation: split at the mid point with a secondary branch
if (depth > 0 && rng() < 0.45) {
const bAng = ang + (rng() < 0.5 ? 0.8 : -0.8) + rnd(-0.3, 0.3);
const bL = L * rnd(0.4, 0.7);
const bPeak: Pt = [mid[0] + Math.cos(bAng) * bL, mid[1] + Math.sin(bAng) * bL];
const bPts: Pt[] = [mid, bPeak, [bPeak[0] + rnd(-10, 10), bPeak[1] + rnd(10, 20)]];
emit(spline(bPts, 4), 'cs-sig-main', `stroke-width:${sw * 0.8};opacity:${op}`);
if (rng() < 0.3) ornament(bPeak, bAng, 0.6);
}
// terminal spike off the outermost point // terminal spike off the outermost point
barb(peak, ang + rnd(-0.5, 0.5), scale * rnd(8, 18)); barb(peak, ang + rnd(-0.4, 0.4), scale * rnd(10, 22));
if (rng() < 0.3) microFilaments(peak, ang);
// filament shadow trailing the main sweep // denser filament shadows trailing the main sweep
if (rng() < 0.4) { const numFilaments = rng() < 0.5 ? 2 : 1;
const off = rnd(2, 6); for (let f = 0; f < numFilaments; f++) {
const off = rnd(2, 8) * (f + 1);
const drift = rnd(-0.2, 0.2);
emit( emit(
spline( spline(
pts.map( pts.map(
([x, y]) => ([x, y]) =>
[x + Math.cos(ang + Math.PI / 2) * off, y + Math.sin(ang + Math.PI / 2) * off] as Pt, [
x + Math.cos(ang + Math.PI / 2 + drift) * off,
y + Math.sin(ang + Math.PI / 2 + drift) * off,
] as Pt,
), ),
6.5,
), ),
'cs-sig-fil', 'cs-sig-fil',
); );
} }
// barb scatter along the chord // barb scatter along the chord
const nb = 1 + Math.floor(rng() * 2); const nb = 2 + Math.floor(rng() * 2);
for (let k = 0; k < nb; k++) { for (let k = 0; k < nb; k++) {
const t = (k + 1) / (nb + 1); const t = (k + 1) / (nb + 1);
const seg = t < 0.5 ? [pts[0], pts[2], t * 2] : [pts[2], pts[4], (t - 0.5) * 2]; const seg = t < 0.5 ? [pts[0], pts[2], t * 2] : [pts[2], pts[4], (t - 0.5) * 2];
@@ -169,74 +226,120 @@ export function buildCybersigil(opts: SigilOptions = {}): string {
const tt = seg[2] as number; const tt = seg[2] as number;
const base: Pt = [a[0] + (b[0] - a[0]) * tt, a[1] + (b[1] - a[1]) * tt]; const base: Pt = [a[0] + (b[0] - a[0]) * tt, a[1] + (b[1] - a[1]) * tt];
const side = k % 2 ? 1 : -1; const side = k % 2 ? 1 : -1;
barb(base, ang + side * rnd(0.6, 1.3), scale * rnd(6, 16)); barb(base, ang + side * rnd(0.7, 1.4), scale * rnd(7, 18));
} }
// recurse — one child curls off the mid/peak region // recurse — child curl
if (depth > 0 && rng() < 0.55) { if (depth > 0 && rng() < 0.6) {
const from = rng() < 0.5 ? mid : peak; const from = rng() < 0.4 ? mid : peak;
limb( limb(
from[0], from[0],
from[1], from[1],
ang + (rng() < 0.5 ? 1 : -1) * rnd(0.5, 1.2), ang + (rng() < 0.5 ? 1 : -1) * rnd(0.6, 1.3),
scale * rnd(0.42, 0.6), scale * rnd(0.45, 0.65),
depth - 1, depth - 1,
); );
} }
// motif flourish at a terminal tip // motif flourish at terminal region
if (depth === 0 && rng() < 0.3) ornament(peak, ang); if (depth === 0 && rng() < 0.35) ornament(peak, ang, 0.9);
}; };
// ── Wavering spine: a curve from top to bottom, gently bowing in +x. // ── Harmonic spine: a weighted wave from top to bottom
const spineNodes = 5 + Math.floor(rng() * 3); const spineNodes = 8 + Math.floor(rng() * 4);
const spinePts: Pt[] = []; const spinePts: Pt[] = [];
const sFreq = rnd(0.8, 1.8);
const sAmp = rnd(8, 15);
for (let i = 0; i <= spineNodes; i++) { for (let i = 0; i <= spineNodes; i++) {
const y = (H * i) / spineNodes; const t = i / spineNodes;
const x = i === 0 || i === spineNodes ? 0 : rnd(0, 11); const y = H * t;
const x = (i === 0 || i === spineNodes) ? 0 :
Math.sin(t * Math.PI * sFreq) * sAmp +
Math.sin(t * Math.PI * sFreq * 2.3) * (sAmp * 0.4);
spinePts.push([x, y]); spinePts.push([x, y]);
} }
emit(spline(spinePts), 'cs-sig-main'); emit(spline(spinePts, 5), 'cs-sig-main cs-sig-spine');
// ── Terminal spike crown: long, straight, downward-converging needles // ── Vascular Filaments: high-frequency "shiver" paths tracking the spine
// off the spine tail — the dripping barbed point that ends the growth. for (let j = 0; j < 2; j++) {
const tip = spinePts[spinePts.length - 1]; const vPts = spinePts.map(([x, y], i) => {
const fan = 4 + Math.floor(rng() * 3); // 46 const t = i / spineNodes;
for (let s = 0; s < fan; s++) { const off = Math.sin(t * 22 + rng() * 6) * 1.8;
const spread = (s / (fan - 1) - 0.5) * 1.0; // ~±0.5 rad — tighter dagger return [x + off + (j === 0 ? -2.5 : 2.5), y] as Pt;
const a = Math.PI / 2 + spread; // π/2 = +y (down) });
const len = rnd(64, 104) * (1 - Math.abs(spread) * 0.35); // center longest emit(spline(vPts, 6.5), 'cs-sig-vessel');
const base: Pt = [tip[0], tip[1] - rnd(0, 8)];
const tt: Pt = [base[0] + Math.cos(a) * len, base[1] + Math.sin(a) * len];
emit(`M${n(base[0])} ${n(base[1])}L${n(tt[0])} ${n(tt[1])}`, 'cs-sig-barb');
track(tt[0]);
} }
// ── Branch nodes ride the spine and throw limbs outward. Nodes are // ── Chromatic Aberration: two faint, offset echoes of the spine
// inset from the very ends and spread the full height so growth flows emit(spline(spinePts.map(([x, y]) => [x - 1.5, y] as Pt), 5), 'cs-sig-spine-ab cs-sig-spine-ab--1');
// down the whole trunk rather than clumping at the top. emit(spline(spinePts.map(([x, y]) => [x + 1.5, y] as Pt), 5), 'cs-sig-spine-ab cs-sig-spine-ab--2');
const nodes = opts.count ?? 7 + Math.floor(rng() * 3); // 79
// terminal ornaments on the spine itself (anchors the design)
ornament(spinePts[0], -Math.PI / 2, 1.1);
ornament(spinePts[spinePts.length - 1], Math.PI / 2, 1.1);
// ── Branch nodes ride the spine and throw limbs outward.
const nodes = opts.count ?? 8 + Math.floor(rng() * 4); // 811
const tangleIdx = rng() < 0.7 ? 1 + Math.floor(rng() * (nodes - 2)) : -1;
const nodePoints: Pt[] = [];
for (let i = 0; i < nodes; i++) { for (let i = 0; i < nodes; i++) {
const t = 0.08 + (0.86 * (i + rnd(-0.25, 0.25))) / (nodes - 1); const isTangle = i === tangleIdx;
const t = 0.1 + (0.8 * (i + rnd(-0.2, 0.2))) / (nodes - 1);
const tc = Math.max(0.05, Math.min(0.95, t)); const tc = Math.max(0.05, Math.min(0.95, t));
const si = Math.min(spinePts.length - 2, Math.floor(tc * (spinePts.length - 1))); const si = Math.min(spinePts.length - 2, Math.floor(tc * (spinePts.length - 1)));
const sf = tc * (spinePts.length - 1) - si; const sf = tc * (spinePts.length - 1) - si;
const a = spinePts[si]; const a = spinePts[si];
const b = spinePts[si + 1]; const b = spinePts[si + 1];
const node: Pt = [a[0] + (b[0] - a[0]) * sf, a[1] + (b[1] - a[1]) * sf]; const node: Pt = [a[0] + (b[0] - a[0]) * sf, a[1] + (b[1] - a[1]) * sf];
// later nodes lean downward so the lower trunk fills out nodePoints.push(node);
const bias = -0.25 + tc * 0.7; const bias = -0.3 + tc * 0.8;
// spike gradient: lower trunk grows longer, sharper barbs
const spike = 1 + tc * 1.4; // 1× top → ~2.3× bottom const limbs = (1 + Math.floor(rng() * 2)) * (isTangle ? 2 : 1);
const limbs = 1 + Math.floor(rng() * 2);
for (let l = 0; l < limbs; l++) { for (let l = 0; l < limbs; l++) {
const ang = bias + rnd(-0.55, 0.55); const ang = bias + rnd(-0.6, 0.6) + (isTangle ? rnd(-1, 1) : 0);
limb(node[0], node[1], ang, rnd(0.65, 1.05) * (0.8 + tc * 0.5), 1); limb(node[0], node[1], ang, rnd(0.7, 1.1) * (isTangle ? 0.7 : 1), 1);
} }
// the odd bare barb straight off the spine keeps the trunk prickly
if (rng() < 0.55) barb(node, bias + rnd(-0.3, 0.3), rnd(7, 14) * spike); if (isTangle || rng() < 0.6) {
barb(node, bias + rnd(-0.4, 0.4), rnd(8, 16) * (isTangle ? 1.5 : 1));
} }
if (isTangle && rng() < 0.5) {
ornament(node, bias, 0.8);
}
if (isTangle) {
// Digital Sediment: tiny bit-dust squares at tangle nodes
for (let j = 0; j < 6; j++) {
emitRect(node[0] + rnd(-12, 12), node[1] + rnd(-12, 12), rnd(1, 3), 'cs-sig-dust');
}
}
}
// ── Technical Connectors: sparse, straight circuit lines
const numConnectors = 1 + Math.floor(rng() * 3);
for (let i = 0; i < numConnectors; i++) {
const n1 = pick(nodePoints);
const n2 = pick(nodePoints);
if (n1 === n2) continue;
emit(`M${n(n1[0])} ${n(n1[1])} L${n(n2[0])} ${n(n2[1])}`, 'cs-sig-connect');
}
// ── Ghost Symbols: tiny technical fragments
const symbols = ['0x00', 'NULL', 'VOID', 'ERR', 'INIT', 'HALT', 'RECLAIM', 'DEAD'];
const numSymbols = 2 + Math.floor(rng() * 3);
for (let i = 0; i < numSymbols; i++) {
const pt = pick(nodePoints);
emitText(pt[0] + rnd(4, 12), pt[1] + rnd(-4, 4), pick(symbols), 'cs-sig-text');
}
// ── Calibration HUD: geometric framing arcs and crosshairs
const hudR = maxX + PAD * 1.5;
emitHUD(`M${n(-hudR)} 0 A${n(hudR)} ${n(hudR)} 0 0 1 ${n(hudR)} 0`, 'cs-sig-hud');
emitHUD(`M${n(-hudR)} ${H} A${n(hudR)} ${n(hudR)} 0 0 0 ${n(hudR)} ${H}`, 'cs-sig-hud');
emitHUD(`M0 ${n(-PAD)} L0 ${n(H + PAD)}`, 'cs-sig-hud cs-sig-hud--v');
const half = parts.join(''); const half = parts.join('');
const minX = -(maxX + PAD); const minX = -(maxX + PAD);
const vbW = 2 * (maxX + PAD); const vbW = 2 * (maxX + PAD);
@@ -244,6 +347,12 @@ export function buildCybersigil(opts: SigilOptions = {}): string {
`<svg class="cs-sigil" viewBox="${n(minX)} ${-PAD} ${n(vbW)} ${H + 2 * PAD}" ` + `<svg class="cs-sigil" viewBox="${n(minX)} ${-PAD} ${n(vbW)} ${H + 2 * PAD}" ` +
`preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false" ` + `preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false" ` +
`xmlns="http://www.w3.org/2000/svg">` + `xmlns="http://www.w3.org/2000/svg">` +
`<defs>` +
`<filter id="cs-erosion" x="-20%" y="-20%" width="140%" height="140%">` +
`<feTurbulence type="fractalNoise" baseFrequency="0.6" numOctaves="3" result="noise" />` +
`<feDisplacementMap in="SourceGraphic" in2="noise" scale="1.5" xChannelSelector="R" yChannelSelector="G" />` +
`</filter>` +
`</defs>` +
`<g class="cs-sig-half">${half}</g>` + `<g class="cs-sig-half">${half}</g>` +
`<g class="cs-sig-half" transform="scale(-1 1)">${half}</g>` + `<g class="cs-sig-half" transform="scale(-1 1)">${half}</g>` +
`</svg>` `</svg>`
+99
View File
@@ -0,0 +1,99 @@
// Site presentation mode. The atelier skin (fonts, cybersigil/breakcore
// themes, paper grain, CyberFx) is identical in both modes — only the *focus*
// flips: `atelier` puts images first (justified gallery plates, "plates"
// count), `blog` puts writing first (stacked rows, excerpts, reading time).
//
// Resolved server-side from the SITE_MODE env var. React islands cannot read
// process.env in the browser, so pages pass the resolved mode down as a prop;
// islands then look up COPY by mode.
export type SiteMode = 'blog' | 'atelier';
/** Server-side only. Defaults to atelier for any unset/unknown value. */
export function getSiteMode(): SiteMode {
const v = typeof process !== 'undefined' ? process.env.SITE_MODE : undefined;
return v === 'blog' ? 'blog' : 'atelier';
}
/**
* Mode-keyed user-facing strings. Atelier keeps the gallery voice; blog
* neutralises it. Anything not voice-flavoured (e.g. "Search", "Cancel")
* stays out of here so there's no needless duplication.
*/
export const COPY = {
atelier: {
indexTitle: 'Catalogue',
backHome: 'Back to catalogue',
adminBack: 'Back to the catalogue',
adminEyebrow: "Artist's desk",
footerEnd: 'end of catalogue',
loadingMore: 'arranging more…',
draftShort: 'Sketch',
draftLong: 'Sketch · unpublished',
searchPlaceholder: 'Search the catalogue…',
searchAria: 'Search the catalogue',
searchFetching: 'Fetching the catalogue…',
searchEmpty: 'The catalogue is empty.',
searchNoMatch: 'No works match.',
deleteListTitle: 'Take this off the wall?',
deleteListMsg: (t: string) =>
`${t}” will be removed from the catalogue. This cannot be undone.`,
deleteListConfirm: 'Remove',
deleteListCancel: 'Keep',
deletePostTitle: 'Delete this work?',
deletePostMsg: (t: string) => `${t}” will be permanently removed. This cannot be undone.`,
postNotFound: 'Work not found in the catalogue',
returnHome: 'Return to the catalogue',
notFoundTitle: 'Not in the catalogue',
notFoundDesc: "The work you're looking for is not on view.",
notFoundRule: 'Pardon — the gallery has misplaced this work',
notFoundHead: 'This piece is not on view.',
notFoundBody:
'The room you reached for has either been re-hung, withdrawn,|or never made it to the wall in the first place.',
editorTitlePh: 'Untitled (charcoal on paper)',
editorSlugPh: 'untitled-charcoal-on-paper',
editorDraftLabel: 'Sketch (draft)',
editorSummaryPh: 'A short caption for the catalogue index...',
editorSummaryLabel: 'Caption (optional)',
editorTagsPh: 'oil, paper, 2026, study',
},
blog: {
indexTitle: 'Posts',
backHome: 'Back to posts',
adminBack: 'Back to posts',
adminEyebrow: 'Dashboard',
footerEnd: 'end of posts',
loadingMore: 'loading more…',
draftShort: 'Draft',
draftLong: 'Draft · unpublished',
searchPlaceholder: 'Search posts…',
searchAria: 'Search posts',
searchFetching: 'Loading posts…',
searchEmpty: 'No posts yet.',
searchNoMatch: 'No posts match.',
deleteListTitle: 'Delete this post?',
deleteListMsg: (t: string) => `${t}” will be permanently deleted. This cannot be undone.`,
deleteListConfirm: 'Delete',
deleteListCancel: 'Cancel',
deletePostTitle: 'Delete this post?',
deletePostMsg: (t: string) => `${t}” will be permanently deleted. This cannot be undone.`,
postNotFound: 'Post not found',
returnHome: 'Return to posts',
notFoundTitle: 'Post not found',
notFoundDesc: "The post you're looking for doesn't exist.",
notFoundRule: 'This page could not be found',
notFoundHead: 'Nothing here.',
notFoundBody:
'The post you reached for has either moved, been unpublished,|or never existed in the first place.',
editorTitlePh: 'Post title',
editorSlugPh: 'post-slug',
editorDraftLabel: 'Draft',
editorSummaryPh: 'A short summary for the index...',
editorSummaryLabel: 'Summary (optional)',
editorTagsPh: 'essay, notes, 2026',
},
} as const satisfies Record<SiteMode, Record<string, unknown>>;
export function copy(mode: SiteMode) {
return COPY[mode];
}
+10 -6
View File
@@ -1,24 +1,28 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { getSiteMode, copy } from '../lib/siteMode';
const c = copy(getSiteMode());
const [bodyA, bodyB] = c.notFoundBody.split('|');
--- ---
<Layout title="Not in the catalogue" description="The work you're looking for is not on view."> <Layout title={c.notFoundTitle} description={c.notFoundDesc}>
<div class="max-w-2xl mx-auto py-16 md:py-24 text-center"> <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"> <div class="numeral text-[var(--mauve)] text-[8rem] md:text-[12rem] leading-none mb-4">
CDIV CDIV
</div> </div>
<div class="section-rule max-w-sm mx-auto mb-8"> <div class="section-rule max-w-sm mx-auto mb-8">
<span class="ornament">✦</span> <span class="ornament">✦</span>
<span>Pardon — the gallery has misplaced this work</span> <span>{c.notFoundRule}</span>
<span class="ornament">✦</span> <span class="ornament">✦</span>
</div> </div>
<h1 class="font-display italic text-3xl md:text-5xl text-[var(--text)] mb-6 leading-tight"> <h1 class="font-display italic text-3xl md:text-5xl text-[var(--text)] mb-6 leading-tight">
This piece is not on view. {c.notFoundHead}
</h1> </h1>
<p class="text-[var(--subtext1)] font-sans text-lg mb-10 leading-relaxed"> <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" /> {bodyA}<br class="hidden md:block" />
or never made it to the wall in the first place. {bodyB}
</p> </p>
<a href="/" class="btn btn--primary">↶ Return to the catalogue</a> <a href="/" class="btn btn--primary">↶ {c.returnHome}</a>
</div> </div>
</Layout> </Layout>
+3 -1
View File
@@ -2,11 +2,13 @@
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import AdminLayout from '../../layouts/AdminLayout.astro'; import AdminLayout from '../../layouts/AdminLayout.astro';
import Editor from '../../components/react/admin/Editor'; import Editor from '../../components/react/admin/Editor';
import { getSiteMode } from '../../lib/siteMode';
const editSlug = Astro.url.searchParams.get('edit') || undefined; const editSlug = Astro.url.searchParams.get('edit') || undefined;
const siteMode = getSiteMode();
--- ---
<AdminLayout title="Write Post" wide> <AdminLayout title="Write Post" wide>
<p slot="header-subtitle" class="mt-2 text-sm md:text-base" style="color: var(--text) !important;">Create/Edit post.</p> <p slot="header-subtitle" class="mt-2 text-sm md:text-base" style="color: var(--text) !important;">Create/Edit post.</p>
<Editor client:only="react" editSlug={editSlug} /> <Editor client:only="react" editSlug={editSlug} mode={siteMode} />
</AdminLayout> </AdminLayout>
+4 -1
View File
@@ -1,8 +1,11 @@
--- ---
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import Login from '../../components/react/admin/Login'; import Login from '../../components/react/admin/Login';
import { getSiteMode } from '../../lib/siteMode';
const siteMode = getSiteMode();
--- ---
<Layout title="Admin Login" description="Sign in to the back room." minimal> <Layout title="Admin Login" description="Sign in to the back room." minimal>
<Login client:only="react" /> <Login client:only="react" mode={siteMode} />
</Layout> </Layout>
+6 -2
View File
@@ -3,6 +3,10 @@ import Layout from '../layouts/Layout.astro';
import PostList from '../components/react/PostList'; import PostList from '../components/react/PostList';
import EditableText from '../components/react/EditableText'; import EditableText from '../components/react/EditableText';
import AssetsButton from '../components/react/AssetsButton'; import AssetsButton from '../components/react/AssetsButton';
import { getSiteMode, copy } from '../lib/siteMode';
const siteMode = getSiteMode();
const c = copy(siteMode);
const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000'; const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000';
@@ -60,7 +64,7 @@ try {
const isAdmin = Astro.cookies.get('admin_session')?.value === '1'; const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
--- ---
<Layout title="Catalogue" description={siteConfig.welcome_subtitle}> <Layout title={c.indexTitle} description={siteConfig.welcome_subtitle}>
{posts[0]?.cover_image?.url && ( {posts[0]?.cover_image?.url && (
<Fragment slot="head"> <Fragment slot="head">
<link rel="preload" as="image" href={posts[0].cover_image.url} fetchpriority="high" /> <link rel="preload" as="image" href={posts[0].cover_image.url} fetchpriority="high" />
@@ -133,5 +137,5 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
</div> </div>
)} )}
{posts.length > 0 && <PostList posts={posts} isAdmin={isAdmin} client:idle />} {posts.length > 0 && <PostList posts={posts} isAdmin={isAdmin} mode={siteMode} client:idle />}
</Layout> </Layout>
+22 -8
View File
@@ -3,6 +3,11 @@ import 'katex/dist/katex.min.css';
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import DeletePostButton from '../../components/react/DeletePostButton'; import DeletePostButton from '../../components/react/DeletePostButton';
import { renderMarkdown } from '../../lib/markdown'; import { renderMarkdown } from '../../lib/markdown';
import { getSiteMode, copy } from '../../lib/siteMode';
const siteMode = getSiteMode();
const isBlog = siteMode === 'blog';
const c = copy(siteMode);
const { slug } = Astro.params; const { slug } = Astro.params;
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000'; const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
@@ -50,7 +55,7 @@ try {
post = await postRes.json(); post = await postRes.json();
html = renderMarkdown(post!.content, post!.dimensions); html = renderMarkdown(post!.content, post!.dimensions);
} else { } else {
error = 'Work not found in the catalogue'; error = c.postNotFound;
} }
} catch (e) { } catch (e) {
const cause = (e as any)?.cause; const cause = (e as any)?.cause;
@@ -73,7 +78,7 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
image={post?.cover_image?.url} image={post?.cover_image?.url}
type="article" type="article"
> >
{post?.cover_image?.url && ( {!isBlog && post?.cover_image?.url && (
<Fragment slot="head"> <Fragment slot="head">
<link rel="preload" as="image" href={post.cover_image.url} fetchpriority="high" /> <link rel="preload" as="image" href={post.cover_image.url} fetchpriority="high" />
</Fragment> </Fragment>
@@ -84,7 +89,7 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
<div class="max-w-2xl mx-auto py-20 md:py-32 text-center"> <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> <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> <h2 class="font-display italic text-3xl md:text-5xl text-[var(--mauve)] mb-6 leading-tight">{error}</h2>
<a href="/" class="btn btn--ghost">← Return to the catalogue</a> <a href="/" class="btn btn--ghost">← {c.returnHome}</a>
</div> </div>
)} )}
@@ -94,7 +99,7 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
<div class="flex flex-wrap items-center justify-between gap-3 mb-10 md:mb-14 font-display italic text-sm text-[var(--subtext0)]"> <div class="flex flex-wrap items-center justify-between gap-3 mb-10 md:mb-14 font-display italic text-sm text-[var(--subtext0)]">
<a href="/" class="back-link"> <a href="/" class="back-link">
<span class="bl-arrow" aria-hidden="true">←</span> <span class="bl-arrow" aria-hidden="true">←</span>
Back to catalogue {c.backHome}
</a> </a>
{isAdmin && ( {isAdmin && (
@@ -103,7 +108,7 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
<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> <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 Edit
</a> </a>
<DeletePostButton slug={post.slug} title={displayTitle} client:idle /> <DeletePostButton slug={post.slug} title={displayTitle} mode={siteMode} client:idle />
</div> </div>
)} )}
</div> </div>
@@ -117,7 +122,12 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
<div class="section-rule max-w-md mx-auto mb-6"> <div class="section-rule max-w-md mx-auto mb-6">
<span class="ornament">✦</span> <span class="ornament">✦</span>
<span>{formatDate(post.date)}</span> <span>{formatDate(post.date)}</span>
{post.image_count > 0 && ( {isBlog ? (
<>
<span class="ornament">·</span>
<span>{post.reading_time} min read</span>
</>
) : post.image_count > 0 && (
<> <>
<span class="ornament">·</span> <span class="ornament">·</span>
<span>{post.image_count} {post.image_count === 1 ? 'plate' : 'plates'}</span> <span>{post.image_count} {post.image_count === 1 ? 'plate' : 'plates'}</span>
@@ -135,7 +145,7 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
{post.draft && ( {post.draft && (
<div class="mt-6 inline-block"> <div class="mt-6 inline-block">
<span class="chip chip-draft"> <span class="chip chip-draft">
Sketch · unpublished {c.draftLong}
</span> </span>
</div> </div>
)} )}
@@ -150,7 +160,11 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
{/* Body — works on paper */} {/* Body — works on paper */}
<div id="post-content" class="prose" set:html={html} /> <div id="post-content" class="prose" set:html={html} />
{(neighbors.prev || neighbors.next) && ( {isBlog ? (
<div class="max-w-3xl mx-auto mt-20 md:mt-28 text-center">
<a href="/" class="btn btn--ghost">← {c.backHome}</a>
</div>
) : (neighbors.prev || neighbors.next) && (
<nav class="post-nav max-w-3xl mx-auto mt-20 md:mt-28 grid grid-cols-1 md:grid-cols-2 gap-6" aria-label="Post navigation"> <nav class="post-nav max-w-3xl mx-auto mt-20 md:mt-28 grid grid-cols-1 md:grid-cols-2 gap-6" aria-label="Post navigation">
{neighbors.prev && ( {neighbors.prev && (
<a href={`/posts/${encodeURIComponent(neighbors.prev.slug)}`} class="group glass p-6 hover:border-[var(--mauve)] transition-colors text-left"> <a href={`/posts/${encodeURIComponent(neighbors.prev.slug)}`} class="group glass p-6 hover:border-[var(--mauve)] transition-colors text-left">
+1
View File
@@ -13,6 +13,7 @@
@import "./partials/20-atmosphere.css"; @import "./partials/20-atmosphere.css";
@import "./partials/30-prose.css"; @import "./partials/30-prose.css";
@import "./partials/40-components.css"; @import "./partials/40-components.css";
@import "./partials/45-blog.css";
@import "./partials/50-controls.css"; @import "./partials/50-controls.css";
@import "./partials/60-breakcore.css"; @import "./partials/60-breakcore.css";
@import "./partials/70-cybersigil.css"; @import "./partials/70-cybersigil.css";
@@ -216,6 +216,11 @@
.prose img { .prose img {
display: block; display: block;
max-width: 100%; max-width: 100%;
/* A tall single image must never overrun the viewport: cap its height and
* let width track aspect so it scales down whole, centred. (figure-row
* images opt out below — their height is already bounded by --row-h.) */
max-height: 85vh;
width: auto;
height: auto; height: auto;
margin: 0 auto; margin: 0 auto;
border: 1px solid var(--surface2); border: 1px solid var(--surface2);
@@ -288,6 +293,7 @@
.prose .figure-row figure img { .prose .figure-row figure img {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
max-height: none;
height: auto; height: auto;
margin: 0; margin: 0;
} }
+176
View File
@@ -0,0 +1,176 @@
/*
* BLOG MODE — writing-first stacked rows.
*
* Same skin as the gallery (display font, palette, theme variants, paper
* grain, CyberFx) — only the *focus* flips: the post's words lead, the cover
* shrinks to a side thumbnail. Everything here is scoped under
* `html.mode-blog`; atelier (the default) never sees these rules, and the
* justified-gallery markup simply isn't emitted in blog mode.
*/
html.mode-blog .post-list {
width: 100%;
max-width: 52rem;
}
/* One post = one row, separated by a hairline like a printed contents page. */
html.mode-blog .post-row {
position: relative;
padding: 1.9rem 0;
border-bottom: 1px solid color-mix(in srgb, var(--surface2) 55%, transparent);
}
html.mode-blog .post-row:first-child {
padding-top: 0;
}
html.mode-blog .post-row:last-of-type {
border-bottom: none;
}
html.breakcore.mode-blog .post-row {
border-bottom-color: color-mix(in srgb, var(--mauve) 30%, transparent);
}
html.mode-blog .post-row-link {
display: flex;
align-items: flex-start;
gap: 1.75rem;
outline: none;
}
html.mode-blog .post-row-body {
flex: 1 1 auto;
min-width: 0;
}
html.mode-blog .post-row-title {
font-family: var(--font-display);
font-style: italic;
font-weight: 600;
font-size: clamp(1.55rem, 1.1rem + 1.8vw, 2.4rem);
line-height: 1.12;
letter-spacing: -0.012em;
color: var(--text);
/* clip-box padding so italic Fraunces descenders survive (same trick as
* .plate-caption-title) */
padding-bottom: 0.08em;
margin-bottom: -0.08em;
transition: color 0.3s ease;
}
html.mode-blog .post-row-link:hover .post-row-title,
html.mode-blog .post-row-link:focus-visible .post-row-title {
color: var(--mauve);
}
html.mode-blog .post-row-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.7rem;
font-family: var(--font-sans);
font-size: 0.78rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--subtext0);
}
html.mode-blog .post-row-meta .sep {
color: var(--overlay0);
}
html.mode-blog .post-row-excerpt {
margin-top: 0.85rem;
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.65;
color: var(--subtext1);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
html.mode-blog .post-row-tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 1rem;
}
/* Side thumbnail — framed like a small plate so the salon material carries
* over. position:relative + overflow:hidden so the cybersigil hover sigil
* (.cs-plate-sig, inset:0) pins to the image box, never the row. */
html.mode-blog .post-row-thumb {
position: relative;
flex: 0 0 auto;
width: clamp(96px, 22vw, 184px);
aspect-ratio: 4 / 3;
overflow: hidden;
background: var(--mantle);
border: 1px solid var(--surface2);
border-radius: 2px;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 50%, transparent),
0 14px 30px -22px rgba(20, 16, 12, 0.5);
transition: box-shadow 0.4s cubic-bezier(0.2, 0.6, 0.2, 1);
}
html.salon-noir.mode-blog .post-row-thumb,
html.gothic.mode-blog .post-row-thumb {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 50%, transparent),
0 14px 30px -22px rgba(0, 0, 0, 0.75);
}
html.breakcore.mode-blog .post-row-thumb {
border-color: color-mix(in srgb, var(--mauve) 40%, var(--surface2));
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--mauve) 28%, transparent),
0 0 22px -10px color-mix(in srgb, var(--mauve) 45%, transparent);
}
html.mode-blog .post-row-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
filter: saturate(0.94) contrast(1.02);
transition: transform 0.7s cubic-bezier(0.2, 0.6, 0.2, 1), filter 0.4s ease;
}
html.mode-blog .post-row-link:hover .post-row-thumb img,
html.mode-blog .post-row-link:focus-visible .post-row-thumb img {
transform: scale(1.04);
filter: saturate(1.05) contrast(1.04);
}
html.mode-blog .post-row-link:hover .post-row-thumb,
html.mode-blog .post-row-link:focus-visible .post-row-thumb {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 62%, transparent),
0 20px 38px -22px rgba(20, 16, 12, 0.55);
}
html.breakcore.mode-blog .post-row-link:hover .post-row-thumb,
html.breakcore.mode-blog .post-row-link:focus-visible .post-row-thumb {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--mauve) 42%, transparent),
0 0 30px -8px color-mix(in srgb, var(--mauve) 55%, transparent);
}
/* Keyboard focus — inset salon ring on the whole row link. */
html.mode-blog .post-row-link:focus-visible {
box-shadow:
inset 0 0 0 2px var(--mauve),
0 0 0 3px color-mix(in srgb, var(--mauve) 35%, transparent);
border-radius: 2px;
}
html.mode-blog .post-row-draft {
font-family: var(--font-display);
font-style: italic;
letter-spacing: 0.04em;
}
/* Tighter stack on phones; thumbnail drops below the text. */
@media (max-width: 600px) {
html.mode-blog .post-row-link {
flex-direction: column-reverse;
gap: 1rem;
}
html.mode-blog .post-row-thumb {
width: 100%;
aspect-ratio: 16 / 9;
}
}
+38 -10
View File
@@ -75,11 +75,26 @@
background: var(--red); background: var(--red);
border-color: var(--red); border-color: var(--red);
} }
/* Pressed/selected state for toggle & tab buttons */ /* Pressed/selected state for toggle & tab buttons. Solid accent fill so an
.btn.is-active { * engaged toggle is unmistakable (label alone is too subtle). The
color: var(--mauve); * `.btn--ghost.is-active` selector carries enough specificity (0,3,0) to beat
border-color: color-mix(in srgb, var(--mauve) 55%, var(--surface2)); * the theme `.cybersigil/.breakcore .btn--ghost` rules (0,2,0) that load in
background: color-mix(in srgb, var(--mauve) 14%, transparent); * later partials — without it the active state is invisible on those themes. */
.btn.is-active,
.btn.btn--ghost.is-active {
color: var(--rosewater);
background: var(--mauve);
border-color: var(--mauve);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--crust) 35%, transparent),
0 0 0 1px color-mix(in srgb, var(--mauve) 45%, transparent);
}
/* The vim toggle is desktop-only — vim mode auto-disables below 768px, so the
* button is a no-op there. Unlayered media query (Tailwind `hidden` would
* lose the cascade to `.btn { display: inline-flex }`). */
@media (max-width: 767px) {
.btn.vim-toggle { display: none !important; }
} }
/* Sizes */ /* Sizes */
@@ -386,11 +401,24 @@ select.topbar-control.theme-select {
color: var(--green); color: var(--green);
} }
/* Salon grid spans driven by --col-span custom prop (avoids Tailwind dynamic class issue). */ /* Justified gallery — JS solves each row's height so widths fill the line
@media (min-width: 768px) { * exactly (PostList.tsx). flex-grow on the tiles absorbs sub-pixel rounding;
.md-col-span { * the pre-measure / no-JS fallback wraps and grows by aspect instead. */
grid-column: span var(--col-span, 6) / span var(--col-span, 6); .just-gallery { width: 100%; }
} .just-row {
display: flex;
flex-wrap: nowrap;
align-items: flex-start;
gap: 18px;
}
.just-row--fallback {
flex-wrap: wrap;
row-gap: 40px;
}
.just-row > .plate-enter { min-width: 0; }
/* Very narrow viewports: let even the fallback stack cleanly. */
@media (max-width: 420px) {
.just-row--fallback > .plate-enter { flex-basis: 100% !important; }
} }
/* Subtle page enter animation for gallery / plaque */ /* Subtle page enter animation for gallery / plaque */
+334 -28
View File
@@ -61,6 +61,85 @@ html.cybersigil body::after {
.cybersigil .salon-atmosphere::before { background: var(--sky); opacity: 0.06; } .cybersigil .salon-atmosphere::before { background: var(--sky); opacity: 0.06; }
.cybersigil .salon-atmosphere::after { background: var(--mauve); opacity: 0.05; } .cybersigil .salon-atmosphere::after { background: var(--mauve); opacity: 0.05; }
/* HUD Overlay */
.cybersigil .cs-hud {
position: fixed;
inset: 0;
padding: 1.5rem;
pointer-events: none;
z-index: 10;
font-family: var(--font-sans);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--sky);
opacity: 0.5;
}
.cybersigil .cs-hud-item { position: absolute; }
.cybersigil .cs-hud--tl { top: 1.5rem; left: 1.5rem; }
.cybersigil .cs-hud--tr { top: 1.5rem; right: 1.5rem; }
.cybersigil .cs-hud--bl { bottom: 1.5rem; left: 1.5rem; }
.cybersigil .cs-hud--br { bottom: 1.5rem; right: 1.5rem; }
.cs-hud-flicker {
animation: cs-flicker 0.4s steps(4) 1;
}
/* Barbed Borders (Stitched Wire) */
.cybersigil .plate,
.cybersigil .btn,
.cybersigil .glass {
border-image-source: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M0 0 H100 V100 H0 Z' fill='none' stroke='%234fe9ff' stroke-width='1.5' stroke-dasharray='4 12'/><path d='M10 0 L15 -5 M30 0 L25 -5 M50 0 L50 -8 M70 0 L75 -5 M90 0 L85 -5' fill='none' stroke='%234fe9ff' stroke-width='1'/><path d='M10 100 L15 105 M30 100 L25 105 M50 100 L50 108 M70 100 L75 105 M90 100 L85 105' fill='none' stroke='%234fe9ff' stroke-width='1'/></svg>");
border-image-slice: 10;
border-image-repeat: stretch;
}
.cybersigil .plate:hover,
.cybersigil .btn:hover {
border-image-source: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M0 0 H100 V100 H0 Z' fill='none' stroke='%23c8327a' stroke-width='2' stroke-dasharray='1 4'/><path d='M5 0 L15 -10 M25 0 L35 -10 M45 0 L55 -10 M65 0 L75 -10 M85 0 L95 -10' fill='none' stroke='%23c8327a' stroke-width='1.5'/></svg>");
animation: cs-flicker 0.2s infinite;
}
/* Boot Overlay */
.cybersigil .cs-boot {
position: fixed;
inset: 0;
z-index: 100;
background: var(--crust);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
animation: cs-boot-fade 0.8s steps(1) 0.6s forwards;
}
.cybersigil .cs-boot-log {
font-family: var(--font-display);
font-size: clamp(0.9rem, 4vw, 1.2rem);
color: var(--sky);
line-height: 1.4;
text-shadow: 0 0 8px var(--sky);
max-width: 90vw;
padding: 1rem;
}
.cybersigil .cs-boot-log p {
overflow: hidden;
white-space: nowrap;
width: 0;
animation: cs-boot-type 0.1s steps(20) forwards;
}
.cybersigil .cs-boot-log p:nth-child(2) { animation-delay: 0.1s; }
.cybersigil .cs-boot-log p:nth-child(3) { animation-delay: 0.2s; }
.cybersigil .cs-boot-log p:nth-child(4) { animation-delay: 0.3s; }
.cybersigil .cs-boot-log p:nth-child(5) { animation-delay: 0.4s; }
@keyframes cs-boot-fade {
0% { opacity: 1; visibility: visible; }
99% { opacity: 0; visibility: visible; }
100% { opacity: 0; visibility: hidden; display: none; }
}
@keyframes cs-boot-type {
to { width: 100%; }
}
/* ─── cs-fx overlay system (DOM in CyberFx.astro) ───────────────────────── /* ─── cs-fx overlay system (DOM in CyberFx.astro) ─────────────────────────
* Inert everywhere; only the cybersigil theme switches it on. Decorative * Inert everywhere; only the cybersigil theme switches it on. Decorative
* layers ride above content at low opacity (pointer-events:none); the sigil * layers ride above content at low opacity (pointer-events:none); the sigil
@@ -72,6 +151,19 @@ html.cybersigil body::after {
inset: 0; inset: 0;
z-index: 9; z-index: 9;
pointer-events: none; pointer-events: none;
/* Lit by CyberFx.astro after init (no hard pop on first paint / theme
* swap). The same opacity is eased down with scroll depth so the ambient
* recedes behind content as you read, then returns near the top. */
opacity: 0;
transition: opacity 0.5s ease;
}
/* Tab hidden → freeze every looping sigil/flicker/tear. Big idle-battery
* win; the layer is purely decorative so a frozen frame is fine. */
.cybersigil .cs-fx.is-paused .cs-fx-corner,
.cybersigil .cs-fx.is-paused .cs-fx-tear,
.cybersigil .cs-fx.is-paused .cs-sigil path,
.cybersigil .cs-fx.is-paused .cs-sigil line {
animation-play-state: paused;
} }
.cybersigil .cs-fx-halftone, .cybersigil .cs-fx-halftone,
.cybersigil .cs-fx-wire, .cybersigil .cs-fx-wire,
@@ -103,21 +195,36 @@ html.cybersigil body::after {
width: 92vmin; width: 92vmin;
height: 92vmin; height: 92vmin;
opacity: 0.14; opacity: 0.14;
transform: translate(-50%, -50%); /* --cs-px/--cs-py: scroll + pointer parallax drift (CyberFx.astro). */
transform: translate(
calc(-50% + var(--cs-px, 0px)),
calc(-50% + var(--cs-py, 0px))
);
will-change: transform;
} }
.cybersigil .cs-fx-wire .cs-sigil { .cybersigil .cs-fx-wire .cs-sigil {
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--sky) 35%, transparent)); filter:
drop-shadow(0 0 4px color-mix(in srgb, var(--sky) 45%, transparent))
drop-shadow(0 0 12px color-mix(in srgb, var(--sky) 20%, transparent))
drop-shadow(0 0 30px color-mix(in srgb, var(--mauve) 12%, transparent));
} }
.cybersigil .cs-fx-wire .cs-sigil path { .cybersigil .cs-fx-wire .cs-sigil path {
animation: cs-redraw 5.5s ease-in-out infinite; animation:
cs-redraw 6.5s cubic-bezier(0.4, 0, 0.2, 1) infinite;
/* negative, per-stroke offset: the field is always mid-carve, never blank */ /* negative, per-stroke offset: the field is always mid-carve, never blank */
animation-delay: calc(var(--i, 0) * -0.34s); animation-delay: calc(var(--i, 0) * -0.4s);
} }
@keyframes cs-redraw { @keyframes cs-redraw {
0% { stroke-dashoffset: 1; } 0% { stroke-dashoffset: 1; opacity: 0.2; }
15% { opacity: 1; }
35% { stroke-dashoffset: 0; } 35% { stroke-dashoffset: 0; }
60% { stroke-dashoffset: 0; } 65% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -1; } 85% { opacity: 1; }
100% { stroke-dashoffset: -1; opacity: 0.2; }
}
@keyframes cs-shimmer {
from { stroke-opacity: 0.8; stroke-width: inherit; }
to { stroke-opacity: 1; stroke-width: calc(1.1 * inherit); }
} }
/* Random horizontal databend tears — bright displaced bars, mostly absent. */ /* Random horizontal databend tears — bright displaced bars, mostly absent. */
@@ -156,6 +263,10 @@ html.cybersigil body::after {
mask: var(--cs-corner) center / contain no-repeat; mask: var(--cs-corner) center / contain no-repeat;
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--sky) 45%, transparent)); filter: drop-shadow(0 0 5px color-mix(in srgb, var(--sky) 45%, transparent));
animation: cs-flicker 7s linear infinite; animation: cs-flicker 7s linear infinite;
/* Pointer parallax via the independent `translate` longhand so it composes
* with the per-corner mirror in `transform` (and the flicker, which only
* touches opacity) without any of them clobbering the others. */
translate: var(--cs-cx, 0px) var(--cs-cy, 0px);
} }
.cybersigil .cs-fx-corner--tl { top: 0; left: 0; } .cybersigil .cs-fx-corner--tl { top: 0; left: 0; }
.cybersigil .cs-fx-corner--tr { top: 0; right: 0; transform: scaleX(-1); animation-delay: -1.7s; } .cybersigil .cs-fx-corner--tr { top: 0; right: 0; transform: scaleX(-1); animation-delay: -1.7s; }
@@ -173,21 +284,102 @@ html.cybersigil body::after {
height: 100%; height: 100%;
overflow: visible; overflow: visible;
} }
@keyframes cs-jitter {
0%, 95% { transform: translate(0,0) scale(1); }
96% { transform: translate(-2px, 1px) scale(1.01); }
97% { transform: translate(3px, -2px) rotate(0.5deg); }
98% { transform: translate(-1px, 2px); }
99% { transform: translate(0,0); }
}
.cybersigil .cs-sigil path { .cybersigil .cs-sigil path {
fill: none; fill: none;
stroke: var(--sky); stroke: var(--sky);
stroke-width: 2; stroke-width: 2.2;
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
vector-effect: non-scaling-stroke; vector-effect: non-scaling-stroke;
stroke-dasharray: 1; stroke-dasharray: 1;
stroke-dashoffset: 1; stroke-dashoffset: 1;
} }
/* Aberration Echoes */
.cybersigil .cs-sig-spine-ab {
stroke-width: 0.8;
opacity: 0.4;
mix-blend-mode: screen;
}
.cybersigil .cs-sig-spine-ab--1 { stroke: var(--mauve); }
.cybersigil .cs-sig-spine-ab--2 { stroke: var(--teal); }
/* Technical Connectors */
.cybersigil .cs-sig-connect {
stroke: var(--sky);
stroke-width: 0.4;
stroke-dasharray: 4 2;
opacity: 0.6;
}
/* Vascular Vessels */
.cybersigil .cs-sig-vessel {
stroke: var(--sky);
stroke-width: 0.2;
opacity: 0.25;
}
/* Micro-filaments */
.cybersigil .cs-sig-micro {
stroke: var(--sky);
stroke-width: 0.1;
opacity: 0.3;
}
/* Ghost Symbols */
.cybersigil .cs-sig-text {
fill: var(--sky);
font-family: var(--font-display);
font-size: 7px;
opacity: 0.45;
letter-spacing: 0.05em;
pointer-events: none;
filter: drop-shadow(0 0 1px var(--sky));
animation: cs-flicker 4s linear infinite;
}
/* Digital Sediment */
.cybersigil .cs-sig-dust {
fill: var(--sky);
opacity: 0.4;
filter: drop-shadow(0 0 1px var(--sky));
}
/* Calibration HUD */
.cybersigil .cs-sig-hud {
fill: none;
stroke: var(--sky);
stroke-width: 0.3;
opacity: 0.12;
stroke-dasharray: 2 6;
}
.cybersigil .cs-sig-hud--v {
stroke-dasharray: 1 15;
opacity: 0.08;
}
/* Stroke-weight tiers — heavy growth, hair filaments, prickly barbs, motifs. */ /* Stroke-weight tiers — heavy growth, hair filaments, prickly barbs, motifs. */
.cybersigil .cs-sigil .cs-sig-main { stroke-width: 2.4; } .cybersigil .cs-sigil .cs-sig-main { stroke-width: 2.8; }
.cybersigil .cs-sigil .cs-sig-fil { stroke-width: 0.9; opacity: 0.5; } .cybersigil .cs-sigil .cs-sig-spine {
.cybersigil .cs-sigil .cs-sig-barb { stroke-width: 1.3; } /* Haptic pulse removed for a more static look */
.cybersigil .cs-sigil .cs-sig-orn { stroke-width: 1.7; opacity: 0.92; } }
@keyframes cs-haptic {
0%, 100% { stroke-width: 2.8; filter: drop-shadow(0 0 2px var(--sky)); }
50% { stroke-width: 3.4; filter: drop-shadow(0 0 8px var(--sky)); }
}
.cybersigil .cs-sigil .cs-sig-fil { stroke-width: 0.6; opacity: 0.35; }
.cybersigil .cs-sigil .cs-sig-barb { stroke-width: 1.5; }
.cybersigil .cs-sigil .cs-sig-orn {
stroke-width: 1.8;
opacity: 0.95;
filter: drop-shadow(0 0 2px var(--sky));
}
@keyframes cs-carve { @keyframes cs-carve {
to { stroke-dashoffset: 0; } to { stroke-dashoffset: 0; }
@@ -405,18 +597,16 @@ html.cybersigil body::after {
animation: cs-scan 0.78s cubic-bezier(0.4, 0, 0.2, 1) 1; animation: cs-scan 0.78s cubic-bezier(0.4, 0, 0.2, 1) 1;
} }
/* Per-plate sigil — spiky bruised-magenta growth that carves over the tile /* Per-plate sigil — spiky bruised-magenta growth that carves over the image
* on contact, then fades back out. Screen-blended so it etches into the * on contact, then fades back out. Screen-blended so it etches into the
* panel rather than masking it. */ * photo rather than masking it. Pinned to the image box itself (inset:0)
* so it always centres on the actual picture, never the stretched tile/row;
* the box's overflow:hidden trims the bleed so it reads as carved-in. */
.cs-plate-sig { display: none; } .cs-plate-sig { display: none; }
.cybersigil .cs-plate-sig { .cybersigil .cs-plate-sig {
display: block; display: block;
position: absolute; position: absolute;
top: -9%; inset: 0;
left: 50%;
width: 46%;
height: 118%;
transform: translateX(-50%);
z-index: 14; z-index: 14;
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
@@ -426,7 +616,9 @@ html.cybersigil body::after {
.cybersigil .cs-plate-sig .cs-sigil path, .cybersigil .cs-plate-sig .cs-sigil path,
.cybersigil .cs-plate-sig .cs-sigil line { .cybersigil .cs-plate-sig .cs-sigil line {
stroke: color-mix(in srgb, var(--mauve) 68%, var(--sky)); stroke: color-mix(in srgb, var(--mauve) 68%, var(--sky));
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--mauve) 55%, transparent)); filter:
drop-shadow(0 0 4px color-mix(in srgb, var(--mauve) 55%, transparent))
drop-shadow(0 0 12px color-mix(in srgb, var(--sky) 25%, transparent));
} }
.cybersigil .plate-enter:hover .cs-plate-sig, .cybersigil .plate-enter:hover .cs-plate-sig,
.cybersigil .plate-enter:focus-within .cs-plate-sig { .cybersigil .plate-enter:focus-within .cs-plate-sig {
@@ -774,24 +966,61 @@ html.cybersigil body::after {
color: var(--mauve); color: var(--mauve);
font-weight: 700; font-weight: 700;
} }
/* Section break — corrupted-terminal sigil rule. A thin ice→magenta scan
* that tears open at center for a glowing barb-sigil glyph, doubled by the
* hard cold offset echo the buttons/plates carry. */
.cybersigil .prose hr { .cybersigil .prose hr {
height: 3px; height: 1px;
opacity: 0.9; margin: 3.75rem auto;
background: repeating-linear-gradient( opacity: 1;
overflow: visible;
background: linear-gradient(
90deg, 90deg,
var(--sky) 0 12px, transparent 0,
var(--mauve) 12px 24px color-mix(in srgb, var(--sky) 12%, transparent) 5%,
color-mix(in srgb, var(--sky) 72%, transparent) 24%,
color-mix(in srgb, var(--sky) 72%, transparent) 42%,
transparent 46%,
transparent 54%,
color-mix(in srgb, var(--mauve) 72%, transparent) 58%,
color-mix(in srgb, var(--mauve) 72%, transparent) 76%,
color-mix(in srgb, var(--mauve) 12%, transparent) 95%,
transparent 100%
); );
box-shadow: 0 0 12px color-mix(in srgb, var(--sky) 42%, transparent); box-shadow: 0 0 16px -3px color-mix(in srgb, var(--sky) 32%, transparent);
} }
/* Hard cold offset echo — the signature drop the rest of the theme carries. */
.cybersigil .prose hr::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: calc(50% + 3px);
height: 1px;
background: inherit;
opacity: 0.3;
transform: none;
}
/* Center glyph — the barb sigil, glowing, seated over the gap. Overrides
* the base rotated-diamond pip entirely. */
.cybersigil .prose hr::before { .cybersigil .prose hr::before {
width: 3.4em;
height: 1.5em;
transform: translate(-50%, -50%);
background: var(--sky); background: var(--sky);
box-shadow: 0 0 8px var(--sky); border-radius: 0;
-webkit-mask: var(--cs-barb) center / contain no-repeat;
mask: var(--cs-barb) center / contain no-repeat;
filter:
drop-shadow(0 0 5px color-mix(in srgb, var(--sky) 55%, transparent))
drop-shadow(0 0 2px color-mix(in srgb, var(--mauve) 50%, transparent));
} }
.cybersigil .prose h3, .cybersigil .prose h3,
.cybersigil .prose h4, .cybersigil .prose h4,
.cybersigil .prose h5 { .cybersigil .prose h5,
.cybersigil .prose h6 {
font-family: var(--font-sans); font-family: var(--font-sans);
font-style: normal;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
} }
@@ -801,6 +1030,7 @@ html.cybersigil body::after {
.cybersigil .prose h4::before { content: "### "; color: var(--sky); opacity: 0.6; } .cybersigil .prose h4::before { content: "### "; color: var(--sky); opacity: 0.6; }
.cybersigil .prose h5 { color: var(--green); } .cybersigil .prose h5 { color: var(--green); }
.cybersigil .prose h5::before { content: "#### "; color: var(--sky); opacity: 0.55; } .cybersigil .prose h5::before { content: "#### "; color: var(--sky); opacity: 0.55; }
.cybersigil .prose h6::before { content: "##### "; color: var(--sky); opacity: 0.5; }
.cybersigil .prose a { .cybersigil .prose a {
text-decoration-style: dotted; text-decoration-style: dotted;
text-decoration-color: color-mix(in srgb, var(--sky) 60%, transparent); text-decoration-color: color-mix(in srgb, var(--sky) 60%, transparent);
@@ -811,6 +1041,82 @@ html.cybersigil body::after {
text-shadow: -1px 0 0 var(--sky), 1px 0 0 var(--mauve); text-shadow: -1px 0 0 var(--sky), 1px 0 0 var(--mauve);
} }
/* Emphasis — VT323 has no real italic, so the base font-display em renders
* as mangled bitmap. Drop to the terminal body and re-encode emphasis as
* colour: magenta em, ice strong with a faint bloom, red-struck del. */
.cybersigil .prose em {
font-family: var(--font-sans);
font-style: normal;
color: var(--mauve);
}
.cybersigil .prose strong {
color: var(--sky);
text-shadow: 0 0 6px color-mix(in srgb, var(--sky) 30%, transparent);
}
.cybersigil .prose del {
text-decoration-color: color-mix(in srgb, var(--red) 80%, transparent);
}
/* Lists — terminal nodes. The salon diamond becomes a square ice cell with
* the hard magenta offset; ordered markers go cold mono, no faux-italic. */
.cybersigil .prose ul > li::before {
width: 0.4em;
height: 0.4em;
top: 0.66em;
transform: none;
background: var(--sky);
box-shadow: 2px 2px 0 0 color-mix(in srgb, var(--mauve) 70%, transparent);
}
.cybersigil .prose ol > li::marker {
color: var(--sky);
font-family: var(--font-sans);
font-style: normal;
}
.cybersigil .prose li > input[type="checkbox"] { accent-color: var(--mauve); }
/* Figure caption — a `// ` log comment, not a salon plaque dash. */
.cybersigil .prose figure figcaption {
font-family: var(--font-sans);
font-style: normal;
letter-spacing: 0.04em;
color: var(--subtext0);
}
.cybersigil .prose figure figcaption::before {
content: "// ";
color: var(--sky);
opacity: 0.85;
}
/* Tables — a sector readout: square ice grid, scanline header, hard offset,
* magenta hover sweep. */
.cybersigil .prose table {
font-family: var(--font-sans);
border-color: color-mix(in srgb, var(--sky) 40%, var(--surface2));
box-shadow:
0 0 0 1px color-mix(in srgb, var(--sky) 14%, transparent),
4px 4px 0 0 color-mix(in srgb, var(--mauve) 22%, var(--crust));
}
.cybersigil .prose thead {
background:
repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0) 0 2px,
color-mix(in srgb, var(--sky) 8%, transparent) 2px 3px,
rgba(0, 0, 0, 0) 3px 4px
),
color-mix(in srgb, var(--crust) 80%, transparent);
}
.cybersigil .prose th {
color: var(--sky);
border-bottom-color: color-mix(in srgb, var(--mauve) 50%, transparent);
}
.cybersigil .prose td {
border-bottom-color: color-mix(in srgb, var(--sky) 20%, transparent);
}
.cybersigil .prose tbody tr:hover {
background: color-mix(in srgb, var(--sky) 7%, transparent);
}
/* Scrollbar + caret — hard cold chrome. */ /* Scrollbar + caret — hard cold chrome. */
.cybersigil { .cybersigil {
scrollbar-color: var(--sky) var(--crust); scrollbar-color: var(--sky) var(--crust);
@@ -0,0 +1 @@
{"version":"4.1.6","results":[[":frontend/src/lib/cybersigil.test.ts",{"duration":66.06918499999995,"failed":false}]]}