mantra v0.2: multi-cycle library + artifacts (cycle 2 + manifest + applied-principles)
This commit is contained in:
parent
716cf74864
commit
d501b1b700
12 changed files with 765 additions and 217 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"))]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
162
crates/mantra-ui/src/pages/artifact.rs
Normal file
162
crates/mantra-ui/src/pages/artifact.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue