Compare commits
15 Commits
aec2bf66fd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b24cfeeac | |||
| 9d326c7f29 | |||
| f7d1620d08 | |||
| 3f2c39e000 | |||
| 02e15ea222 | |||
| 3680a12761 | |||
| c6c9819ab0 | |||
| 0d518e94d4 | |||
| eba4543953 | |||
| 0da5b24dc3 | |||
| 37f88f5ad1 | |||
| 4fae8b5e47 | |||
| 0bd27dd7ef | |||
| 39bbd9e925 | |||
| 72c09a2ff2 |
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||

|
Post content goes here. In `atelier` mode, include at least one image:
|
||||||
|
|
||||||
Notes on the piece: materials, references, what worked, what didn't.
|

|
||||||
|
|
||||||

|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `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
|
||||||
|
|||||||
@@ -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 ( in the markdown body)."
|
"A gallery entry must include at least one image ( in the markdown body)."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
|||||||
+21
-2
@@ -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()),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
> "The painter constructs, the photographer discloses." — Susan Sontag
|
|
||||||
@@ -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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Replace this entry with your first real work, or remove it from the catalogue via the admin dashboard.
|
|
||||||
+9
-8
@@ -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,12 +35,12 @@ 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=${PUBLIC_API_URL:-}
|
- PUBLIC_API_URL=${PUBLIC_API_URL:-}
|
||||||
- SITE_MODE=${SITE_MODE:-atelier}
|
- SITE_MODE=${SITE_MODE:-atelier}
|
||||||
@@ -48,7 +49,7 @@ services:
|
|||||||
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:
|
||||||
@@ -56,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 |
@@ -18,6 +18,23 @@
|
|||||||
<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>
|
||||||
@@ -78,19 +95,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Ambient: entrance fade-in (opacity:0 → target via the CSS
|
/* ─── Ambient: entrance fade-in + scroll recede + parallax ─── */
|
||||||
* transition on first apply) + scroll-depth recede. Parallax is
|
|
||||||
* disabled for now — the --cs-px/py/cx/cy vars default to 0px so the
|
|
||||||
* wire/corner transforms stay put; re-enable by driving those vars
|
|
||||||
* from scroll/pointer here again. ─── */
|
|
||||||
if (teardown) teardown();
|
if (teardown) teardown();
|
||||||
const off: Array<() => void> = [];
|
const off: Array<() => void> = [];
|
||||||
let depth = 0, raf = 0;
|
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 = () => {
|
const apply = () => {
|
||||||
raf = 0;
|
raf = 0;
|
||||||
fx.style.opacity = String(1 - 0.5 * depth);
|
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 schedule = () => { if (!raf) raf = requestAnimationFrame(apply); };
|
||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
@@ -98,10 +166,18 @@
|
|||||||
depth = Math.max(0, Math.min(1, window.scrollY / vh));
|
depth = Math.max(0, Math.min(1, window.scrollY / vh));
|
||||||
schedule();
|
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('scroll', onScroll, { passive: true });
|
||||||
window.addEventListener('resize', onScroll);
|
window.addEventListener('resize', onScroll);
|
||||||
|
window.addEventListener('mousemove', onMouseMove, { passive: true });
|
||||||
off.push(() => window.removeEventListener('scroll', onScroll));
|
off.push(() => window.removeEventListener('scroll', onScroll));
|
||||||
off.push(() => window.removeEventListener('resize', onScroll));
|
off.push(() => window.removeEventListener('resize', onScroll));
|
||||||
|
off.push(() => window.removeEventListener('mousemove', onMouseMove));
|
||||||
onScroll();
|
onScroll();
|
||||||
|
|
||||||
/* Freeze every loop while the tab is hidden — idle-battery win. */
|
/* Freeze every loop while the tab is hidden — idle-battery win. */
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function usePostMeta({ editSlug, getContent, setContent, mode = 'atelier'
|
|||||||
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',
|
||||||
|
|||||||
+176
-51
@@ -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,58 +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');
|
||||||
|
|
||||||
|
// ── Vascular Filaments: high-frequency "shiver" paths tracking the spine
|
||||||
|
for (let j = 0; j < 2; j++) {
|
||||||
|
const vPts = spinePts.map(([x, y], i) => {
|
||||||
|
const t = i / spineNodes;
|
||||||
|
const off = Math.sin(t * 22 + rng() * 6) * 1.8;
|
||||||
|
return [x + off + (j === 0 ? -2.5 : 2.5), y] as Pt;
|
||||||
|
});
|
||||||
|
emit(spline(vPts, 6.5), 'cs-sig-vessel');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chromatic Aberration: two faint, offset echoes of the spine
|
||||||
|
emit(spline(spinePts.map(([x, y]) => [x - 1.5, y] as Pt), 5), 'cs-sig-spine-ab cs-sig-spine-ab--1');
|
||||||
|
emit(spline(spinePts.map(([x, y]) => [x + 1.5, y] as Pt), 5), 'cs-sig-spine-ab cs-sig-spine-ab--2');
|
||||||
|
|
||||||
|
// 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); // 8–11
|
||||||
|
const tangleIdx = rng() < 0.7 ? 1 + Math.floor(rng() * (nodes - 2)) : -1;
|
||||||
|
const nodePoints: Pt[] = [];
|
||||||
|
|
||||||
// ── Branch nodes ride the spine and throw limbs outward. Nodes are
|
|
||||||
// inset from the very ends and spread the full height so growth flows
|
|
||||||
// down the whole trunk rather than clumping at the top.
|
|
||||||
const nodes = opts.count ?? 7 + Math.floor(rng() * 3); // 7–9
|
|
||||||
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;
|
||||||
const limbs = 1 + Math.floor(rng() * 2);
|
|
||||||
|
const limbs = (1 + Math.floor(rng() * 2)) * (isTangle ? 2 : 1);
|
||||||
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), 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));
|
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);
|
||||||
@@ -228,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>`
|
||||||
|
|||||||
@@ -78,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>
|
||||||
@@ -113,20 +113,6 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Blog mode: cover rides above the plaque as a lead image. In atelier
|
|
||||||
the cover is the index plate's job, not the post page's. */}
|
|
||||||
{isBlog && post.cover_image?.url && (
|
|
||||||
<div class="post-lead">
|
|
||||||
<img
|
|
||||||
src={post.cover_image.url}
|
|
||||||
alt={post.cover_image.alt || displayTitle}
|
|
||||||
width={post.cover_image.w}
|
|
||||||
height={post.cover_image.h}
|
|
||||||
fetchpriority="high"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Plaque header */}
|
{/* Plaque header */}
|
||||||
<header class="max-w-3xl mx-auto text-center mb-12 md:mb-16">
|
<header class="max-w-3xl mx-auto text-center mb-12 md:mb-16">
|
||||||
<h1 class="font-display italic font-semibold text-[var(--text)] text-4xl md:text-6xl lg:text-7xl leading-[1.08] tracking-tight mb-6">
|
<h1 class="font-display italic font-semibold text-[var(--text)] text-4xl md:text-6xl lg:text-7xl leading-[1.08] tracking-tight mb-6">
|
||||||
@@ -174,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">
|
||||||
|
|||||||
@@ -174,35 +174,3 @@ html.mode-blog .post-row-draft {
|
|||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Post page (blog mode): cover as a lead image above the plaque ── */
|
|
||||||
html.mode-blog .post-lead {
|
|
||||||
position: relative;
|
|
||||||
max-width: 48rem;
|
|
||||||
margin: 0 auto 2.5rem;
|
|
||||||
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 26px 50px -30px rgba(20, 16, 12, 0.55);
|
|
||||||
}
|
|
||||||
html.salon-noir.mode-blog .post-lead,
|
|
||||||
html.gothic.mode-blog .post-lead {
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 1px color-mix(in srgb, var(--surface1) 50%, transparent),
|
|
||||||
0 26px 50px -30px rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
html.breakcore.mode-blog .post-lead {
|
|
||||||
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 34px -12px color-mix(in srgb, var(--mauve) 45%, transparent);
|
|
||||||
}
|
|
||||||
html.mode-blog .post-lead img {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
filter: saturate(0.95) contrast(1.02);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -124,18 +203,28 @@ html.cybersigil body::after {
|
|||||||
will-change: transform;
|
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. */
|
||||||
@@ -195,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; }
|
||||||
@@ -446,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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user