mantra v0.2: multi-cycle library + artifacts (cycle 2 + manifest + applied-principles)

This commit is contained in:
Alexey 2026-04-24 14:52:13 +05:00
parent 716cf74864
commit d501b1b700
12 changed files with 765 additions and 217 deletions

View file

@ -1,11 +1,21 @@
//! Loads the design-dna corpus from a directory of markdown files. //! Loads the design-dna corpus from a directory of markdown files.
//! //!
//! Layout expected (relative to MANTRA_CONTENT_DIR): //! Layout expected (relative to MANTRA_CONTENT_DIR):
//! cycle-1-philosophy/ //! cycle-1-philosophy/ ← EN cycle 1 sources
//! _index.md ← corpus-level index with themes //! _index.md
//! <source-slug>.md × N ← one distillation per source //! <source-slug>.md × N
//! cycle-1-philosophy-ru/ ← RU cycle 1 sources (parallel to en)
//! _index.md
//! <source-slug>.md × N
//! cycle-2-design-theory/ ← cycle 2 sources (Russian only for v0.2)
//! _index.md
//! <source-slug>.md × N
//! manifest.md ← cycle 1 closing artifact
//! applied-principles.md ← cycle 2 closing artifact
//! //!
//! The loader returns an in-memory `Corpus` ready for SSR renders. //! Each lang build of the Corpus includes: cycle-1 in that lang +
//! cycle-2 (Russian) + artifacts (Russian). For lang=en, cycles 2 and
//! artifacts show Russian content — cycle 2 is not yet translated.
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
@ -19,7 +29,7 @@ use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use serde::Deserialize; use serde::Deserialize;
use mantra_ui::corpus::{Corpus, Source, Theme}; use mantra_ui::corpus::{Artifact, Corpus, Cycle, Source, Theme};
#[derive(Deserialize, Debug, Default)] #[derive(Deserialize, Debug, Default)]
struct SourceFrontmatter { struct SourceFrontmatter {
@ -33,23 +43,102 @@ struct SourceFrontmatter {
_kind: String, _kind: String,
} }
pub fn load_corpus(root: &Path) -> Result<Corpus> { #[derive(Deserialize, Debug, Default)]
load_corpus_alt_cycle_dir(root, "cycle-1-philosophy") struct ArtifactFrontmatter {
#[serde(default, rename = "type")]
_kind: String,
#[serde(default)]
cycle: Option<u64>,
#[serde(default)]
closes: Option<String>,
} }
pub fn load_corpus_alt_cycle_dir(root: &Path, cycle_subdir: &str) -> Result<Corpus> { /// Language mode for cycle 1. Cycle 2 is Russian-only for v0.2.
let cycle_dir = root.join(cycle_subdir); #[derive(Debug, Clone, Copy)]
if !cycle_dir.is_dir() { pub enum CycleOneLang {
return Err(anyhow!( En,
"content dir missing {} subdir: {}", Ru,
cycle_subdir, }
cycle_dir.display()
)); /// Load the entire corpus for one language. Cycle 1 is language-aware;
/// cycle 2 and artifacts are always loaded from their Russian-only
/// source and shown as-is in both language modes.
pub fn load_corpus(root: &Path, lang: CycleOneLang) -> Result<Corpus> {
let mut cycles: Vec<Cycle> = Vec::new();
// --- Cycle 1 (bilingual) ---
let cycle1_subdir = match lang {
CycleOneLang::En => "cycle-1-philosophy",
CycleOneLang::Ru => "cycle-1-philosophy-ru",
};
let cycle1_dir = root.join(cycle1_subdir);
if cycle1_dir.is_dir() {
let (title, subtitle) = match lang {
CycleOneLang::En => ("philosophy", "cycle 1 · 20 sources"),
CycleOneLang::Ru => ("философия", "цикл 1 · 20 источников"),
};
cycles.push(load_cycle(
&cycle1_dir,
"cycle-1-philosophy",
title,
subtitle,
)?);
} }
// First pass: gather all source files (exclude _index.md). // --- Cycle 2 (Russian only for v0.2) ---
let cycle2_dir = root.join("cycle-2-design-theory");
if cycle2_dir.is_dir() {
let (title, subtitle) = match lang {
CycleOneLang::En => ("design theory", "cycle 2 · 14 masters · в переводе"),
CycleOneLang::Ru => ("теория дизайна", "цикл 2 · 14 мастеров"),
};
cycles.push(load_cycle(
&cycle2_dir,
"cycle-2-design-theory",
title,
subtitle,
)?);
}
// --- Artifacts (Russian only for v0.2) ---
let mut artifacts: Vec<Artifact> = Vec::new();
for (slug, title_ru, title_en, subtitle_ru, subtitle_en) in [
(
"manifest",
"манифест",
"manifesto",
"что делает форму живой · цикл 1",
"what makes form alive · cycle 1",
),
(
"applied-principles",
"прикладные принципы",
"applied principles",
"невидимая эротика + 5 принципов · цикл 2",
"invisible eros + 5 principles · cycle 2",
),
] {
let path = root.join(format!("{slug}.md"));
if path.exists() {
let (title, subtitle) = match lang {
CycleOneLang::En => (title_en, subtitle_en),
CycleOneLang::Ru => (title_ru, subtitle_ru),
};
artifacts.push(load_artifact(&path, slug, title, subtitle)?);
}
}
Ok(Corpus { cycles, artifacts })
}
fn load_cycle(
cycle_dir: &Path,
cycle_slug: &str,
title: &str,
subtitle: &str,
) -> Result<Cycle> {
let mut source_paths: Vec<PathBuf> = Vec::new(); let mut source_paths: Vec<PathBuf> = Vec::new();
for entry in fs::read_dir(&cycle_dir)? { for entry in fs::read_dir(cycle_dir)? {
let path = entry?.path(); let path = entry?.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue; continue;
@ -67,24 +156,63 @@ pub fn load_corpus_alt_cycle_dir(root: &Path, cycle_subdir: &str) -> Result<Corp
let mut sources: HashMap<String, Source> = HashMap::new(); let mut sources: HashMap<String, Source> = HashMap::new();
let mut order: Vec<String> = Vec::new(); let mut order: Vec<String> = Vec::new();
for path in &source_paths { for path in &source_paths {
let src = parse_source(path).with_context(|| { let src = parse_source(path, cycle_slug).with_context(|| {
format!("parsing source {}", path.display()) format!("parsing source {}", path.display())
})?; })?;
order.push(src.slug.clone()); order.push(src.slug.clone());
sources.insert(src.slug.clone(), src); sources.insert(src.slug.clone(), src);
} }
// Themes come from the _index.md if present; skip gracefully otherwise. let themes = parse_themes(&cycle_dir.join("_index.md"), cycle_slug)
let themes = parse_themes(&cycle_dir.join("_index.md"))
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
tracing::warn!("no themes loaded: {e}"); tracing::warn!("no themes loaded for {cycle_slug}: {e}");
Vec::new() Vec::new()
}); });
Ok(Corpus { order, sources, themes }) Ok(Cycle {
slug: cycle_slug.to_string(),
title: title.to_string(),
subtitle: subtitle.to_string(),
order,
sources,
themes,
})
} }
fn parse_source(path: &Path) -> Result<Source> { fn load_artifact(
path: &Path,
slug: &str,
title: &str,
subtitle: &str,
) -> Result<Artifact> {
let raw = fs::read_to_string(path)?;
let matter = Matter::<YAML>::new();
let parsed = matter.parse(&raw);
let fm: ArtifactFrontmatter = parsed
.data
.as_ref()
.and_then(|p| p.deserialize().ok())
.unwrap_or_default();
let body_md = parsed.content;
let body_html = md_to_html(&body_md);
let closes_cycle = fm.cycle.map(|n| match n {
1 => "cycle-1-philosophy".to_string(),
2 => "cycle-2-design-theory".to_string(),
other => format!("cycle-{other}"),
}).or(fm.closes);
Ok(Artifact {
slug: slug.to_string(),
title: title.to_string(),
subtitle: subtitle.to_string(),
closes_cycle,
body_html,
})
}
fn parse_source(path: &Path, cycle_slug: &str) -> Result<Source> {
let raw = fs::read_to_string(path)?; let raw = fs::read_to_string(path)?;
let matter = Matter::<YAML>::new(); let matter = Matter::<YAML>::new();
let parsed = matter.parse(&raw); let parsed = matter.parse(&raw);
@ -101,7 +229,6 @@ fn parse_source(path: &Path) -> Result<Source> {
.ok_or_else(|| anyhow!("bad filename: {}", path.display()))? .ok_or_else(|| anyhow!("bad filename: {}", path.display()))?
.to_string(); .to_string();
// Extract H1 (### replaces some files — just grab the first #-line).
let title = body_md let title = body_md
.lines() .lines()
.find(|l| l.starts_with("# ")) .find(|l| l.starts_with("# "))
@ -135,10 +262,10 @@ fn parse_source(path: &Path) -> Result<Source> {
fm.confidence fm.confidence
}, },
body_html, body_html,
cycle: cycle_slug.to_string(),
}) })
} }
/// Extract text content between a given heading and the next `## ` heading.
fn extract_section(md: &str, heading: &str) -> Option<String> { fn extract_section(md: &str, heading: &str) -> Option<String> {
let mut out = String::new(); let mut out = String::new();
let mut capturing = false; let mut capturing = false;
@ -158,11 +285,6 @@ fn extract_section(md: &str, heading: &str) -> Option<String> {
if out.trim().is_empty() { None } else { Some(out) } if out.trim().is_empty() { None } else { Some(out) }
} }
/// Render markdown to HTML and inject stable `data-para-id` attributes
/// on every `<p>` element. The id is a short content-hash: stable
/// across minor edits of the surrounding text, unique enough within a
/// single source document. The margin-layer UI uses these ids to
/// anchor notes and Claude-asks to specific paragraphs.
fn md_to_html(md: &str) -> String { fn md_to_html(md: &str) -> String {
let mut opts = Options::empty(); let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES); opts.insert(Options::ENABLE_TABLES);
@ -171,15 +293,10 @@ fn md_to_html(md: &str) -> String {
opts.insert(Options::ENABLE_TASKLISTS); opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_SMART_PUNCTUATION); opts.insert(Options::ENABLE_SMART_PUNCTUATION);
// First pass: render plain HTML.
let parser = Parser::new_ext(md, opts); let parser = Parser::new_ext(md, opts);
let mut raw = String::new(); let mut raw = String::new();
html::push_html(&mut raw, parser); html::push_html(&mut raw, parser);
// Second pass: re-parse to capture paragraph text for hashing, and
// inject data-para-id. pulldown-cmark emits events in order, so we
// can walk the source again, track paragraphs, compute hashes, and
// then do a simple textual rewrite on the output.
let parser2 = Parser::new_ext(md, opts); let parser2 = Parser::new_ext(md, opts);
let mut current_para_text = String::new(); let mut current_para_text = String::new();
let mut in_para = false; let mut in_para = false;
@ -201,12 +318,6 @@ fn md_to_html(md: &str) -> String {
} }
} }
// Inject attributes: replace each `<p>` (in order) with
// `<p data-para-id="hash">`. We walk the byte buffer to find the
// ASCII literal `<p>`, but copy UTF-8 slices (not byte-by-byte)
// to preserve non-ASCII correctly — the earlier `push(u8 as char)`
// approach silently decoded multibyte UTF-8 as Latin-1 and turned
// em-dashes / smart-quotes / Cyrillic / Greek into mojibake.
let mut out = String::with_capacity(raw.len() + para_hashes.len() * 24); let mut out = String::with_capacity(raw.len() + para_hashes.len() * 24);
let mut pending = para_hashes.into_iter(); let mut pending = para_hashes.into_iter();
let bytes = raw.as_bytes(); let bytes = raw.as_bytes();
@ -214,7 +325,6 @@ fn md_to_html(md: &str) -> String {
let mut i = 0usize; let mut i = 0usize;
while i + 3 <= bytes.len() { while i + 3 <= bytes.len() {
if &bytes[i..i + 3] == b"<p>" { if &bytes[i..i + 3] == b"<p>" {
// flush everything from last_copied..i as a proper str slice
out.push_str(&raw[last_copied..i]); out.push_str(&raw[last_copied..i]);
if let Some(id) = pending.next() { if let Some(id) = pending.next() {
out.push_str(&format!(r#"<p data-para-id="{id}">"#)); out.push_str(&format!(r#"<p data-para-id="{id}">"#));
@ -237,24 +347,16 @@ fn short_hash(s: &str) -> String {
format!("{:08x}", h.finish() & 0xFFFF_FFFF) format!("{:08x}", h.finish() & 0xFFFF_FFFF)
} }
/// Parse `_index.md` for the 12 running themes + their contributing sources. fn parse_themes(index_path: &Path, cycle_slug: &str) -> Result<Vec<Theme>> {
///
/// Format assumption: each theme is introduced by `### {n}. {title}`
/// with the following paragraph carrying a comma-separated list of
/// author names. We map author names back to slugs by substring-match
/// on the source files' author field. Fallback if pattern doesn't
/// match: empty themes list, landing shows empty themes column.
fn parse_themes(index_path: &Path) -> Result<Vec<Theme>> {
let raw = fs::read_to_string(index_path)?; let raw = fs::read_to_string(index_path)?;
let mut themes: Vec<Theme> = Vec::new(); let mut themes: Vec<Theme> = Vec::new();
let mut current: Option<(String, String, String)> = None; // (slug, title, desc_md) let mut current: Option<(String, String, String)> = None;
for line in raw.lines() { for line in raw.lines() {
if let Some(rest) = line.strip_prefix("### ") { if let Some(rest) = line.strip_prefix("### ") {
if let Some((slug, title, desc)) = current.take() { if let Some((slug, title, desc)) = current.take() {
themes.push(finalize_theme(slug, title, desc)); themes.push(finalize_theme(slug, title, desc, cycle_slug));
} }
// e.g. "### 1. Emptiness / absence как носитель формы"
let rest = rest.trim(); let rest = rest.trim();
let after_num = rest let after_num = rest
.split_once('.') .split_once('.')
@ -263,9 +365,8 @@ fn parse_themes(index_path: &Path) -> Result<Vec<Theme>> {
let slug = slugify(&after_num); let slug = slugify(&after_num);
current = Some((slug, after_num, String::new())); current = Some((slug, after_num, String::new()));
} else if line.starts_with("## ") { } else if line.starts_with("## ") {
// exit "running themes" section — fallthrough to push last theme
if let Some((slug, title, desc)) = current.take() { if let Some((slug, title, desc)) = current.take() {
themes.push(finalize_theme(slug, title, desc)); themes.push(finalize_theme(slug, title, desc, cycle_slug));
} }
} else if let Some((_, _, desc)) = current.as_mut() { } else if let Some((_, _, desc)) = current.as_mut() {
desc.push_str(line); desc.push_str(line);
@ -273,12 +374,17 @@ fn parse_themes(index_path: &Path) -> Result<Vec<Theme>> {
} }
} }
if let Some((slug, title, desc)) = current.take() { if let Some((slug, title, desc)) = current.take() {
themes.push(finalize_theme(slug, title, desc)); themes.push(finalize_theme(slug, title, desc, cycle_slug));
} }
Ok(themes) Ok(themes)
} }
fn finalize_theme(slug: String, title: String, desc_md: String) -> Theme { fn finalize_theme(
slug: String,
title: String,
desc_md: String,
cycle_slug: &str,
) -> Theme {
let contributing = parse_theme_contributors(&desc_md); let contributing = parse_theme_contributors(&desc_md);
let description_html = md_to_html(desc_md.trim()); let description_html = md_to_html(desc_md.trim());
Theme { Theme {
@ -286,16 +392,11 @@ fn finalize_theme(slug: String, title: String, desc_md: String) -> Theme {
title, title,
description_html, description_html,
contributing, contributing,
cycle: cycle_slug.to_string(),
} }
} }
/// Very loose extraction: pull parenthesis-grouped author names out of
/// the theme description and attempt to match them to known sources.
/// Since we don't yet have the corpus at this point, we just return a
/// best-guess list of tokens — the landing component can filter by
/// membership later if desired.
fn parse_theme_contributors(desc: &str) -> Vec<String> { fn parse_theme_contributors(desc: &str) -> Vec<String> {
// Heuristic: grab author names from the first parenthetical group.
let Some(start) = desc.find('(') else { let Some(start) = desc.find('(') else {
return Vec::new(); return Vec::new();
}; };
@ -308,7 +409,6 @@ fn parse_theme_contributors(desc: &str) -> Vec<String> {
.map(|s| s.trim()) .map(|s| s.trim())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(|s| { .map(|s| {
// Strip parenthetical hints like "Laozi (ch. 11)" → "Laozi"
s.split_whitespace() s.split_whitespace()
.next() .next()
.unwrap_or(s) .unwrap_or(s)

View file

@ -1,7 +1,9 @@
//! mantra server — axum + leptos_axum SSR. //! mantra server — axum + leptos_axum SSR.
//! //!
//! Loads the corpus from disk once at startup (env `MANTRA_CONTENT_DIR`), //! Loads the corpus from disk once at startup (env `MANTRA_CONTENT_DIR`),
//! hands a shared `Arc<Corpus>` to every render pass via Leptos context. //! hands a shared `Arc<BilingualCorpus>` to every render pass via
//! Leptos context. Server functions also get the context so they can
//! read corpus data during their SSR-side execution.
#![recursion_limit = "256"] #![recursion_limit = "256"]
@ -24,6 +26,8 @@ use tracing::info;
use mantra_ui::app::{shell, App}; use mantra_ui::app::{shell, App};
use mantra_ui::corpus::{BilingualCorpus, BilingualHandle}; use mantra_ui::corpus::{BilingualCorpus, BilingualHandle};
use crate::corpus_loader::CycleOneLang;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
@ -38,46 +42,34 @@ async fn main() -> Result<()> {
std::env::var("MANTRA_CONTENT_DIR") std::env::var("MANTRA_CONTENT_DIR")
.unwrap_or_else(|_| "content".to_string()), .unwrap_or_else(|_| "content".to_string()),
); );
info!("loading bilingual corpus from {}", content_dir.display()); info!("loading design-dna corpus from {}", content_dir.display());
// The loader expects a language-specific subdir; we call it twice let en = corpus_loader::load_corpus(&content_dir, CycleOneLang::En)?;
// against the two sibling directories. let ru = corpus_loader::load_corpus(&content_dir, CycleOneLang::Ru)?;
let en = corpus_loader::load_corpus(&content_dir)?;
// ru lives under ../<content_dir>-ru relative path — or env-overridden.
let ru_dir = std::env::var("MANTRA_CONTENT_DIR_RU")
.map(PathBuf::from)
.unwrap_or_else(|_| {
// derive from content_dir by replacing trailing component
let mut p = content_dir.clone();
let last = p
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| String::from("content"));
p.pop();
p.push(format!("{last}-ru"));
p
});
// Hack for symlinked content: our Mac dev uses `content` symlink to
// design-dna/, so content-ru doesn't exist at peer — fall back to the
// sibling `cycle-1-philosophy-ru` under the DESIGN-DNA root instead.
let ru = if ru_dir.is_dir() {
corpus_loader::load_corpus(&ru_dir)?
} else {
// if MANTRA_CONTENT_DIR is design-dna root, then ru lives under
// <content_dir>/cycle-1-philosophy-ru — but that's a file layout
// difference, handled inside load_corpus_ru.
corpus_loader::load_corpus_alt_cycle_dir(&content_dir, "cycle-1-philosophy-ru")?
};
let bilingual: BilingualHandle = Arc::new(BilingualCorpus { let bilingual: BilingualHandle = Arc::new(BilingualCorpus {
ru: Arc::new(ru), ru: Arc::new(ru),
en: Arc::new(en), en: Arc::new(en),
}); });
for cyc in &bilingual.ru.cycles {
info!( info!(
"loaded: ru={} sources / en={} sources, themes={}", "cycle ru={} sources={} themes={}",
bilingual.ru.sources.len(), cyc.slug,
bilingual.en.sources.len(), cyc.sources.len(),
bilingual.ru.themes.len() cyc.themes.len()
);
}
for cyc in &bilingual.en.cycles {
info!(
"cycle en={} sources={} themes={}",
cyc.slug,
cyc.sources.len(),
cyc.themes.len()
);
}
info!(
"artifacts loaded: {}",
bilingual.ru.artifacts.len()
); );
let conf = get_configuration(None) let conf = get_configuration(None)

View file

@ -9,13 +9,12 @@ pub mod types;
use leptos::prelude::*; use leptos::prelude::*;
pub use types::{LandingData, NoteEntry, NoteKind, SourcePageData, ThemePageData}; pub use types::{
ArtifactPageData, ArtifactRef, CycleSnapshot, LandingData, NoteEntry, NoteKind,
SourcePageData, ThemePageData,
};
// --- fetch_source_page ------------------------------------------------- // --- fetch_source_page -------------------------------------------------
//
// Snapshot for `/source/:slug`. Moves corpus access into a server fn
// so SSR and hydrate render from the same Resource payload and the
// client doesn't need the full `Arc<Corpus>` in context.
#[server(endpoint = "source_page")] #[server(endpoint = "source_page")]
pub async fn fetch_source_page( pub async fn fetch_source_page(
@ -29,12 +28,13 @@ pub async fn fetch_source_page(
.ok_or_else(|| ServerFnError::new("no corpus in context"))?; .ok_or_else(|| ServerFnError::new("no corpus in context"))?;
let lang = Lang::from_query(Some(&lang)); let lang = Lang::from_query(Some(&lang));
let corpus = bilingual.for_lang(lang); let corpus = bilingual.for_lang(lang);
let Some(src) = corpus.get(&slug).cloned() else { let Some((cycle, src)) = corpus.get(&slug).map(|(c, s)| (c.clone(), s.clone())) else {
return Ok(None); return Ok(None);
}; };
let (prev, next) = corpus.neighbors(&slug); let (prev, next) = corpus.neighbors(&slug);
Ok(Some(SourcePageData { Ok(Some(SourcePageData {
source: src, source: src,
cycle,
prev: prev.map(|s| s.to_string()), prev: prev.map(|s| s.to_string()),
next: next.map(|s| s.to_string()), next: next.map(|s| s.to_string()),
})) }))
@ -57,16 +57,39 @@ pub async fn fetch_landing(lang: String) -> Result<LandingData, ServerFnError> {
.ok_or_else(|| ServerFnError::new("no corpus in context"))?; .ok_or_else(|| ServerFnError::new("no corpus in context"))?;
let lang = Lang::from_query(Some(&lang)); let lang = Lang::from_query(Some(&lang));
let corpus = bilingual.for_lang(lang); let corpus = bilingual.for_lang(lang);
let sources = corpus
let cycles = corpus
.cycles
.iter()
.map(|c| {
let sources = c
.order .order
.iter() .iter()
.filter_map(|slug| corpus.get(slug).cloned()) .filter_map(|slug| c.sources.get(slug).cloned())
.collect(); .collect();
Ok(LandingData { CycleSnapshot {
order: corpus.order.clone(), slug: c.slug.clone(),
title: c.title.clone(),
subtitle: c.subtitle.clone(),
order: c.order.clone(),
sources, sources,
themes: corpus.themes.clone(), themes: c.themes.clone(),
}
}) })
.collect();
let artifacts = corpus
.artifacts
.iter()
.map(|a| ArtifactRef {
slug: a.slug.clone(),
title: a.title.clone(),
subtitle: a.subtitle.clone(),
closes_cycle: a.closes_cycle.clone(),
})
.collect();
Ok(LandingData { cycles, artifacts })
} }
#[cfg(not(feature = "ssr"))] #[cfg(not(feature = "ssr"))]
{ {
@ -89,15 +112,47 @@ pub async fn fetch_theme_page(
.ok_or_else(|| ServerFnError::new("no corpus in context"))?; .ok_or_else(|| ServerFnError::new("no corpus in context"))?;
let lang = Lang::from_query(Some(&lang)); let lang = Lang::from_query(Some(&lang));
let corpus = bilingual.for_lang(lang); let corpus = bilingual.for_lang(lang);
let Some(theme) = corpus.themes.iter().find(|t| t.slug == slug).cloned() else { let Some((cycle, theme)) = corpus
.get_theme(&slug)
.map(|(c, t)| (c.clone(), t.clone())) else {
return Ok(None); return Ok(None);
}; };
let contributing = theme let contributing = theme
.contributing .contributing
.iter() .iter()
.filter_map(|s| corpus.get(s).cloned()) .filter_map(|s| cycle.sources.get(s).cloned())
.collect(); .collect();
Ok(Some(ThemePageData { theme, contributing })) Ok(Some(ThemePageData {
theme,
cycle,
contributing,
}))
}
#[cfg(not(feature = "ssr"))]
{
let _ = (slug, lang);
unreachable!()
}
}
// --- fetch_artifact ----------------------------------------------------
#[server(endpoint = "artifact_page")]
pub async fn fetch_artifact(
slug: String,
lang: String,
) -> Result<Option<ArtifactPageData>, ServerFnError> {
#[cfg(feature = "ssr")]
{
use crate::corpus::{BilingualHandle, Lang};
let bilingual = use_context::<BilingualHandle>()
.ok_or_else(|| ServerFnError::new("no corpus in context"))?;
let lang = Lang::from_query(Some(&lang));
let corpus = bilingual.for_lang(lang);
let Some(artifact) = corpus.get_artifact(&slug).cloned() else {
return Ok(None);
};
Ok(Some(ArtifactPageData { artifact }))
} }
#[cfg(not(feature = "ssr"))] #[cfg(not(feature = "ssr"))]
{ {

View file

@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::corpus::{Source, Theme}; use crate::corpus::{Artifact, Cycle, Source, Theme};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum NoteKind { pub enum NoteKind {
@ -20,27 +20,53 @@ pub struct NoteEntry {
pub body: String, pub body: String,
} }
/// Snapshot of a single source + its neighbors for the source page. /// A cycle-shaped snapshot for the landing page: everything needed to
/// Carries everything the page needs so SSR and hydrate render /// render one cycle's section (works list + themes list).
/// identically from the same payload.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourcePageData { pub struct CycleSnapshot {
pub source: Source, pub slug: String,
pub prev: Option<String>, pub title: String,
pub next: Option<String>, pub subtitle: String,
}
/// Snapshot for the landing page — works in reading order + themes.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LandingData {
pub order: Vec<String>, pub order: Vec<String>,
pub sources: Vec<Source>, pub sources: Vec<Source>,
pub themes: Vec<Theme>, pub themes: Vec<Theme>,
} }
/// Small artifact reference for landing-page listing (no body).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ArtifactRef {
pub slug: String,
pub title: String,
pub subtitle: String,
pub closes_cycle: Option<String>,
}
/// Landing page snapshot — all cycles + all artifacts.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LandingData {
pub cycles: Vec<CycleSnapshot>,
pub artifacts: Vec<ArtifactRef>,
}
/// Snapshot of a single source + its neighbors for the source page.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourcePageData {
pub source: Source,
pub cycle: Cycle,
pub prev: Option<String>,
pub next: Option<String>,
}
/// Snapshot for the theme page — one theme + contributing sources. /// Snapshot for the theme page — one theme + contributing sources.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ThemePageData { pub struct ThemePageData {
pub theme: Theme, pub theme: Theme,
pub cycle: Cycle,
pub contributing: Vec<Source>, pub contributing: Vec<Source>,
} }
/// Full artifact body for the artifact page.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ArtifactPageData {
pub artifact: Artifact,
}

View file

@ -41,6 +41,7 @@ pub fn App() -> impl IntoView {
<Route path=path!("/") view=pages::landing::Landing /> <Route path=path!("/") view=pages::landing::Landing />
<Route path=path!("/source/:slug") view=pages::source::SourcePage /> <Route path=path!("/source/:slug") view=pages::source::SourcePage />
<Route path=path!("/theme/:slug") view=pages::theme::ThemePage /> <Route path=path!("/theme/:slug") view=pages::theme::ThemePage />
<Route path=path!("/artifact/:slug") view=pages::artifact::ArtifactPage />
</Routes> </Routes>
</Router> </Router>
} }

View file

@ -1,51 +1,103 @@
//! Corpus data types — shared between SSR render pass and UI components. //! Corpus data types — shared between SSR render pass and UI components.
//! //!
//! The server crate owns `load_corpus` and populates the structures //! The server crate owns the loaders and populates the structures from
//! from disk; the UI crate only reads them. Everything here is //! disk; the UI crate only reads them. Everything here is
//! `Serialize + Deserialize + Clone` so it survives Leptos Resources //! `Serialize + Deserialize + Clone` so it survives Leptos Resources
//! and possible future hydration round-trips. //! and possible future hydration round-trips.
//!
//! Library structure:
//! Corpus
//! └─ cycles: Vec<Cycle> ← each cycle is one research pass
//! ├─ slug, title, subtitle
//! ├─ order: reading order of source slugs
//! ├─ sources: slug → Source (distillation)
//! └─ themes: running patterns extracted from _index.md
//! └─ artifacts: Vec<Artifact> ← closing documents (manifest, applied-principles)
//! └─ slug, title, closes_cycle, body_html
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
/// The entire loaded corpus. Shared via Leptos context as `Arc<Corpus>`. /// One research cycle: a parallel distillation pass over a set of
#[derive(Debug, Clone, Serialize, Deserialize)] /// sources, bound by a root question. cycle-1-philosophy asks "why does
pub struct Corpus { /// form move the human heart?"; cycle-2-design-theory asks "how was
/// Ordered list of source slugs (reading order — chronological or /// that practiced by masters?".
/// thematic; driven by `_index.md`'s table of works). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Cycle {
pub slug: String,
pub title: String,
pub subtitle: String,
pub order: Vec<String>, pub order: Vec<String>,
/// Slug → Source lookup.
pub sources: HashMap<String, Source>, pub sources: HashMap<String, Source>,
/// Running themes extracted from `_index.md`.
pub themes: Vec<Theme>, pub themes: Vec<Theme>,
} }
/// A closing document of a cycle — manifest (cycle 1), applied-principles
/// (cycle 2), and future cycle 3's visual-signature document.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Artifact {
pub slug: String,
pub title: String,
pub subtitle: String,
pub closes_cycle: Option<String>,
pub body_html: String,
}
/// The entire loaded corpus. Shared via Leptos context as `Arc<Corpus>`
/// on the server; UI reads via server fns + Resources.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Corpus {
pub cycles: Vec<Cycle>,
pub artifacts: Vec<Artifact>,
}
impl Corpus { impl Corpus {
pub fn get(&self, slug: &str) -> Option<&Source> { /// Find a source by slug across ALL cycles.
self.sources.get(slug) pub fn get(&self, slug: &str) -> Option<(&Cycle, &Source)> {
for cycle in &self.cycles {
if let Some(src) = cycle.sources.get(slug) {
return Some((cycle, src));
}
}
None
} }
/// Index-in-reading-order of `slug`, if present. /// Neighbors (prev, next) within the same cycle — wrap-around.
pub fn index_of(&self, slug: &str) -> Option<usize> {
self.order.iter().position(|s| s == slug)
}
/// Neighbor (prev, next) by reading order — wrap-around.
pub fn neighbors(&self, slug: &str) -> (Option<&str>, Option<&str>) { pub fn neighbors(&self, slug: &str) -> (Option<&str>, Option<&str>) {
let Some(i) = self.index_of(slug) else { return (None, None) }; for cycle in &self.cycles {
let n = self.order.len(); let Some(i) = cycle.order.iter().position(|s| s == slug) else {
let prev = if n > 1 { continue;
Some(self.order[(i + n - 1) % n].as_str())
} else {
None
}; };
let next = if n > 1 { let n = cycle.order.len();
Some(self.order[(i + 1) % n].as_str()) if n <= 1 {
} else { return (None, None);
}
let prev = Some(cycle.order[(i + n - 1) % n].as_str());
let next = Some(cycle.order[(i + 1) % n].as_str());
return (prev, next);
}
(None, None)
}
/// Find a theme by slug across ALL cycles.
pub fn get_theme(&self, slug: &str) -> Option<(&Cycle, &Theme)> {
for cycle in &self.cycles {
if let Some(t) = cycle.themes.iter().find(|t| t.slug == slug) {
return Some((cycle, t));
}
}
None None
}; }
(prev, next)
/// Find an artifact by slug.
pub fn get_artifact(&self, slug: &str) -> Option<&Artifact> {
self.artifacts.iter().find(|a| a.slug == slug)
}
/// Get a cycle by slug.
pub fn get_cycle(&self, slug: &str) -> Option<&Cycle> {
self.cycles.iter().find(|c| c.slug == slug)
} }
} }
@ -65,6 +117,8 @@ pub struct Source {
pub confidence: String, pub confidence: String,
/// The full markdown body rendered to HTML. /// The full markdown body rendered to HTML.
pub body_html: String, pub body_html: String,
/// Which cycle this source belongs to.
pub cycle: String,
} }
/// A running theme — pattern that proustep across multiple sources. /// A running theme — pattern that proustep across multiple sources.
@ -76,6 +130,8 @@ pub struct Theme {
pub description_html: String, pub description_html: String,
/// Sources that contribute to this theme (by slug). /// Sources that contribute to this theme (by slug).
pub contributing: Vec<String>, pub contributing: Vec<String>,
/// Which cycle this theme belongs to.
pub cycle: String,
} }
/// Convenience alias — we always thread the corpus through context as /// Convenience alias — we always thread the corpus through context as
@ -104,9 +160,9 @@ impl Lang {
} }
} }
/// Bilingual container — same slugs in both languages. Each language /// Bilingual container — same slugs in both languages where possible.
/// is held as an `Arc<Corpus>` so `for_lang` is cheap and callers can /// Cycle 1 is bilingual; cycle 2 and artifacts are Russian-only, and
/// keep their own handle without deep-cloning a 60kw corpus. /// both language variants point at the same data for those.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BilingualCorpus { pub struct BilingualCorpus {
pub ru: Arc<Corpus>, pub ru: Arc<Corpus>,

View file

@ -0,0 +1,162 @@
//! `/artifact/:slug` — closing document of a cycle.
//!
//! Currently renders: manifest.md (cycle 1) and applied-principles.md
//! (cycle 2). Same book-grade typography as the source page, same
//! paragraph-id → margin drawer wiring so readers can annotate the
//! artifacts too.
use leptos::prelude::*;
use leptos_router::hooks::{use_params, use_query_map};
use leptos_router::params::Params;
use crate::api::{fetch_artifact, ArtifactPageData};
use crate::corpus::Lang;
use crate::pages::margin::{ActivePara, MarginDrawer};
use crate::pages::shared::LangToggle;
#[derive(Params, PartialEq, Clone, Debug)]
struct SlugParam {
slug: Option<String>,
}
#[component]
pub fn ArtifactPage() -> impl IntoView {
let params = use_params::<SlugParam>();
let slug = Memo::new(move |_| {
params.get().ok().and_then(|p| p.slug).unwrap_or_default()
});
let query = use_query_map();
let lang = Memo::new(move |_| {
Lang::from_query(query.read().get("lang").as_deref())
});
let (active, set_active) = signal::<Option<ActivePara>>(None);
let notes_tick = RwSignal::new(0u32);
let slug_sig: Signal<String> = Signal::derive(move || slug.get());
let lang_sig: Signal<Lang> = Signal::derive(move || lang.get());
let data = Resource::new(
move || (slug.get(), lang.get().as_str().to_string()),
|(s, l)| fetch_artifact(s, l),
);
#[cfg(feature = "hydrate")]
{
let slug_for_fx = slug_sig;
Effect::new(move |_| {
let _ = notes_tick.get();
let current_slug = slug_for_fx.get();
if current_slug.is_empty() { return; }
leptos::task::spawn_local(async move {
if let Ok(entries) = crate::api::fetch_notes(current_slug).await {
let ids: std::collections::HashSet<String> =
entries.into_iter().map(|e| e.para_id).collect();
annotate_notes(&ids);
}
});
});
}
view! {
<LangToggle current=lang/>
<Suspense fallback=move || view! { <p class="source-loading">""</p> }>
{move || {
data.get().map(|res| {
let lang_val = lang.get();
let home_href = format!("/?lang={}", lang_val.as_str());
let label_back = match lang_val {
Lang::Ru => "все источники",
Lang::En => "all sources",
};
match res {
Ok(Some(ArtifactPageData { artifact })) => view! {
<main class="source artifact">
<nav class="source-breadcrumb">
<a href=home_href.clone()>"design dna"</a>
<span class="sep">" · "</span>
<span class="source-author">{artifact.subtitle}</span>
</nav>
<article
class="source-body"
inner_html=artifact.body_html
on:click=move |ev| {
if let Some(p) = paragraph_from_event(&ev) {
set_active.set(Some(p));
}
}
></article>
<footer class="source-foot">
<a class="nav-home" href=home_href>{label_back}</a>
</footer>
</main>
}.into_any(),
Ok(None) => view! {
<main class="source not-found">
<p>"artifact not found"</p>
<a class="back-link" href=home_href>"← back"</a>
</main>
}.into_any(),
Err(e) => view! {
<main class="source not-found">
<p class="err">{format!("{e}")}</p>
</main>
}.into_any(),
}
})
}}
</Suspense>
<MarginDrawer
slug=slug_sig
lang=lang_sig
active=active
set_active=set_active
notes_tick=notes_tick
/>
}
}
#[cfg(feature = "hydrate")]
fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<ActivePara> {
use wasm_bindgen::JsCast;
let target = ev.target()?;
let mut el = target.dyn_into::<web_sys::Element>().ok()?;
loop {
if el.has_attribute("data-para-id") {
let id = el.get_attribute("data-para-id")?;
let text = el.text_content().unwrap_or_default();
let excerpt = text.trim().to_string();
return Some(ActivePara { id, excerpt });
}
el = el.parent_element()?;
}
}
#[cfg(not(feature = "hydrate"))]
fn paragraph_from_event(_ev: &leptos::ev::MouseEvent) -> Option<ActivePara> {
None
}
#[cfg(feature = "hydrate")]
fn annotate_notes(ids: &std::collections::HashSet<String>) {
use wasm_bindgen::JsCast;
let Some(win) = web_sys::window() else { return };
let Some(doc) = win.document() else { return };
let Ok(nodes) = doc.query_selector_all("[data-para-id]") else { return };
for i in 0..nodes.length() {
let Some(node) = nodes.item(i) else { continue };
let Ok(el) = node.dyn_into::<web_sys::Element>() else { continue };
let id = el.get_attribute("data-para-id").unwrap_or_default();
let classes = el.class_list();
if ids.contains(&id) {
let _ = classes.add_1("has-notes");
} else {
let _ = classes.remove_1("has-notes");
}
}
}

View file

@ -1,9 +1,13 @@
//! `/` — two-door entry into the corpus: works (20) and themes. //! `/` — two-door entry into the design-dna library.
//!
//! Shows all cycles (each with its works + themes columns) plus the
//! artifacts strip. v0.2: cycle 1 (philosophy) + cycle 2 (design
//! theory) + manifest + applied-principles.
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::hooks::use_query_map; use leptos_router::hooks::use_query_map;
use crate::api::{fetch_landing, LandingData}; use crate::api::{fetch_landing, ArtifactRef, CycleSnapshot, LandingData};
use crate::corpus::{Lang, Source, Theme}; use crate::corpus::{Lang, Source, Theme};
use crate::pages::shared::LangToggle; use crate::pages::shared::LangToggle;
@ -24,13 +28,17 @@ pub fn Landing() -> impl IntoView {
Lang::En => "design dna", Lang::En => "design dna",
}; };
let hero_subtitle = move || match lang.get() { let hero_subtitle = move || match lang.get() {
Lang::Ru => "цикл 1 · философия · 20 источников", Lang::Ru => "философия · теория дизайна · дистилляция",
Lang::En => "cycle 1 · philosophy · 20 sources", Lang::En => "philosophy · design theory · distillation",
}; };
let hero_question = move || match lang.get() { let hero_question = move || match lang.get() {
Lang::Ru => "почему форма трогает человеческое сердце?", Lang::Ru => "почему форма трогает человеческое сердце?",
Lang::En => "why does form move the human heart?", Lang::En => "why does form move the human heart?",
}; };
let artifacts_label = move || match lang.get() {
Lang::Ru => "артефакты",
Lang::En => "artifacts",
};
let works_label = move || match lang.get() { let works_label = move || match lang.get() {
Lang::Ru => "работы", Lang::Ru => "работы",
Lang::En => "works", Lang::En => "works",
@ -39,10 +47,6 @@ pub fn Landing() -> impl IntoView {
Lang::Ru => "темы", Lang::Ru => "темы",
Lang::En => "themes", Lang::En => "themes",
}; };
let sources_suffix = move |n: usize| match lang.get() {
Lang::Ru => format!(" · {n} источников"),
Lang::En => format!(" · {n} sources"),
};
view! { view! {
<main class="landing"> <main class="landing">
@ -57,49 +61,43 @@ pub fn Landing() -> impl IntoView {
{move || { {move || {
data.get().map(|res| { data.get().map(|res| {
let lang_val = lang.get(); let lang_val = lang.get();
let suffix_for_themes = sources_suffix.clone();
match res { match res {
Ok(LandingData { order, sources, themes }) => { Ok(LandingData { cycles, artifacts }) => {
// Build slug -> Source map for cheap lookup in the For body
let mut by_slug: std::collections::HashMap<String, Source> =
std::collections::HashMap::with_capacity(sources.len());
for s in sources { by_slug.insert(s.slug.clone(), s); }
view! { view! {
<section class="landing-grid"> {if !artifacts.is_empty() {
<div class="landing-col"> let art_label = artifacts_label();
<h2 class="landing-col-label">{works_label}</h2> view! {
<ul class="works-list"> <section class="landing-artifacts">
<h2 class="landing-section-label">{art_label}</h2>
<ul class="artifacts-list">
<For <For
each=move || order.clone() each=move || artifacts.clone()
key=|slug| slug.clone() key=|a| a.slug.clone()
let:slug let:artifact
> >
<WorkLine <ArtifactLine
source=by_slug.get(&slug).cloned() artifact=artifact
lang=lang_val lang=lang_val
slug=slug
/> />
</For> </For>
</ul> </ul>
</div>
<div class="landing-col">
<h2 class="landing-col-label">{themes_label}</h2>
<ul class="themes-list">
<For
each=move || themes.clone()
key=|t| t.slug.clone()
let:theme
>
<ThemeLine
theme=theme
lang=lang_val
suffix=suffix_for_themes.clone()
/>
</For>
</ul>
</div>
</section> </section>
}.into_any() }.into_any()
} else { ().into_any() }}
<For
each=move || cycles.clone()
key=|c| c.slug.clone()
let:cycle
>
<CycleSection
cycle=cycle
lang=lang_val
works_label=works_label()
themes_label=themes_label()
/>
</For>
}.into_any()
} }
Err(e) => view! { <p class="err">{format!("{e}")}</p> }.into_any(), Err(e) => view! { <p class="err">{format!("{e}")}</p> }.into_any(),
} }
@ -110,6 +108,74 @@ pub fn Landing() -> impl IntoView {
} }
} }
#[component]
fn CycleSection(
cycle: CycleSnapshot,
lang: Lang,
works_label: &'static str,
themes_label: &'static str,
) -> impl IntoView {
let cycle_title = cycle.title.clone();
let cycle_subtitle = cycle.subtitle.clone();
let sources = cycle.sources.clone();
let themes = cycle.themes.clone();
let order = cycle.order.clone();
let mut by_slug: std::collections::HashMap<String, Source> =
std::collections::HashMap::with_capacity(sources.len());
for s in sources {
by_slug.insert(s.slug.clone(), s);
}
let sources_suffix = move |n: usize| match lang {
Lang::Ru => format!(" · {n} источников"),
Lang::En => format!(" · {n} sources"),
};
view! {
<section class="landing-cycle">
<header class="landing-cycle-head">
<h2 class="landing-cycle-title">{cycle_title}</h2>
<p class="landing-cycle-subtitle">{cycle_subtitle}</p>
</header>
<div class="landing-grid">
<div class="landing-col">
<h3 class="landing-col-label">{works_label}</h3>
<ul class="works-list">
<For
each=move || order.clone()
key=|slug| slug.clone()
let:slug
>
<WorkLine
source=by_slug.get(&slug).cloned()
lang=lang
slug=slug
/>
</For>
</ul>
</div>
<div class="landing-col">
<h3 class="landing-col-label">{themes_label}</h3>
<ul class="themes-list">
<For
each=move || themes.clone()
key=|t| t.slug.clone()
let:theme
>
<ThemeLine
theme=theme
lang=lang
suffix=sources_suffix.clone()
/>
</For>
</ul>
</div>
</div>
</section>
}
}
#[component] #[component]
fn WorkLine(source: Option<Source>, slug: String, lang: Lang) -> impl IntoView { fn WorkLine(source: Option<Source>, slug: String, lang: Lang) -> impl IntoView {
let href = format!("/source/{slug}?lang={}", lang.as_str()); let href = format!("/source/{slug}?lang={}", lang.as_str());
@ -144,3 +210,17 @@ where
</li> </li>
} }
} }
#[component]
fn ArtifactLine(artifact: ArtifactRef, lang: Lang) -> impl IntoView {
let href = format!("/artifact/{}?lang={}", artifact.slug, lang.as_str());
view! {
<li class="artifact-line">
<a href=href>
<span class="artifact-title">{artifact.title}</span>
<span class="artifact-sep">" · "</span>
<span class="artifact-subtitle">{artifact.subtitle}</span>
</a>
</li>
}
}

View file

@ -1,3 +1,4 @@
pub mod artifact;
pub mod landing; pub mod landing;
pub mod margin; pub mod margin;
pub mod shared; pub mod shared;

View file

@ -69,10 +69,6 @@ pub fn SourcePage() -> impl IntoView {
data.get().map(|res| { data.get().map(|res| {
let lang_val = lang.get(); let lang_val = lang.get();
let home_href = format!("/?lang={}", lang_val.as_str()); let home_href = format!("/?lang={}", lang_val.as_str());
let breadcrumb_cycle = match lang_val {
Lang::Ru => "цикл 1",
Lang::En => "cycle 1",
};
let label_all = match lang_val { let label_all = match lang_val {
Lang::Ru => "все источники", Lang::Ru => "все источники",
Lang::En => "all sources", Lang::En => "all sources",
@ -88,7 +84,7 @@ pub fn SourcePage() -> impl IntoView {
match res { match res {
Ok(Some(d)) => { Ok(Some(d)) => {
let SourcePageData { source: src, prev, next } = d; let SourcePageData { source: src, cycle, prev, next } = d;
let prev_href = prev.map(|p| format!("/source/{p}?lang={}", lang_val.as_str())); let prev_href = prev.map(|p| format!("/source/{p}?lang={}", lang_val.as_str()));
let next_href = next.map(|n| format!("/source/{n}?lang={}", lang_val.as_str())); let next_href = next.map(|n| format!("/source/{n}?lang={}", lang_val.as_str()));
view! { view! {
@ -96,7 +92,7 @@ pub fn SourcePage() -> impl IntoView {
<nav class="source-breadcrumb"> <nav class="source-breadcrumb">
<a href=home_href.clone()>"design dna"</a> <a href=home_href.clone()>"design dna"</a>
<span class="sep">" · "</span> <span class="sep">" · "</span>
<span>{breadcrumb_cycle}</span> <span>{cycle.subtitle}</span>
<span class="sep">" · "</span> <span class="sep">" · "</span>
<span class="source-author">{src.author}</span> <span class="source-author">{src.author}</span>
</nav> </nav>

View file

@ -51,11 +51,13 @@ pub fn ThemePage() -> impl IntoView {
}; };
match res { match res {
Ok(Some(ThemePageData { theme, contributing })) => view! { Ok(Some(ThemePageData { theme, cycle, contributing })) => view! {
<main class="theme"> <main class="theme">
<nav class="source-breadcrumb"> <nav class="source-breadcrumb">
<a href=home_href.clone()>"design dna"</a> <a href=home_href.clone()>"design dna"</a>
<span class="sep">" · "</span> <span class="sep">" · "</span>
<span>{cycle.subtitle}</span>
<span class="sep">" · "</span>
<span>{theme_label}</span> <span>{theme_label}</span>
</nav> </nav>
<h1 class="theme-h1">{theme.title}</h1> <h1 class="theme-h1">{theme.title}</h1>

View file

@ -111,7 +111,7 @@ main {
// --- Landing -------------------------------------------------------- // --- Landing --------------------------------------------------------
.landing { .landing {
max-width: 72ch; max-width: 74ch;
} }
.landing-head { margin-bottom: 4rem; } .landing-head { margin-bottom: 4rem; }
@ -148,6 +148,83 @@ main {
max-width: 34ch; max-width: 34ch;
} }
.landing-section-label {
font-family: var(--mantra-sans);
font-variation-settings: "wght" 500;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.28em;
color: var(--mantra-muted);
margin: 0 0 1.5rem;
}
.landing-artifacts {
margin-bottom: 5rem;
}
.artifacts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.9rem;
border-top: 1px solid var(--mantra-hairline);
border-bottom: 1px solid var(--mantra-hairline);
padding: 1rem 0;
}
.artifact-line a {
display: block;
border-bottom: none;
line-height: 1.4;
}
.artifact-title {
font-family: var(--mantra-serif);
font-variation-settings: "opsz" 22, "SOFT" 40, "WONK" 0, "wght" 520;
font-size: 1.05rem;
color: var(--mantra-fg);
}
.artifact-sep { color: var(--mantra-faint); font-size: 0.85rem; }
.artifact-subtitle {
font-style: italic;
color: var(--mantra-muted);
font-size: 0.88rem;
}
.artifact-line a:hover .artifact-title { color: var(--mantra-accent); }
.landing-cycle {
margin-bottom: 4.5rem;
padding-top: 2rem;
border-top: 1px solid var(--mantra-hairline);
&:first-of-type { border-top: none; padding-top: 0; }
}
.landing-cycle-head {
margin-bottom: 2.5rem;
}
.landing-cycle-title {
font-family: var(--mantra-serif);
font-variation-settings: "opsz" 60, "SOFT" 50, "WONK" 0, "wght" 400;
font-size: clamp(1.6rem, 3vw, 2.1rem);
line-height: 1.1;
letter-spacing: -0.01em;
margin: 0;
color: var(--mantra-fg);
}
.landing-cycle-subtitle {
font-family: var(--mantra-sans);
font-variation-settings: "wght" 400;
font-size: 0.72rem;
color: var(--mantra-muted);
text-transform: uppercase;
letter-spacing: 0.2em;
margin: 0.4rem 0 0;
}
.landing-grid { .landing-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;