diff --git a/Cargo.lock b/Cargo.lock index 20b9281..6c5a6c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1419,6 +1419,7 @@ version = "0.1.0" dependencies = [ "chrono", "console_error_panic_hook", + "js-sys", "leptos", "leptos_meta", "leptos_router", diff --git a/crates/mantra-server/src/corpus_loader.rs b/crates/mantra-server/src/corpus_loader.rs index 7710e7a..56bdcb9 100644 --- a/crates/mantra-server/src/corpus_loader.rs +++ b/crates/mantra-server/src/corpus_loader.rs @@ -239,6 +239,7 @@ fn parse_source(path: &Path, cycle_slug: &str) -> Result { .unwrap_or_default() .trim() .replace('\n', " "); + let core_claim_html = render_inline_md(&core_claim); let author = fm .source @@ -255,6 +256,7 @@ fn parse_source(path: &Path, cycle_slug: &str) -> Result { title, author, core_claim, + core_claim_html, tags: fm.tags, confidence: if fm.confidence.is_empty() { "medium".into() @@ -266,6 +268,24 @@ fn parse_source(path: &Path, cycle_slug: &str) -> Result { }) } +/// Render short inline markdown (a sentence or short paragraph) to HTML. +/// Strips the outer `

` wrap so the result can drop straight into any +/// inline context. Used for core-claim snippets in hero sections. +fn render_inline_md(s: &str) -> String { + let mut opts = Options::empty(); + opts.insert(Options::ENABLE_SMART_PUNCTUATION); + let parser = Parser::new_ext(s, opts); + let mut out = String::new(); + html::push_html(&mut out, parser); + let trimmed = out.trim(); + trimmed + .strip_prefix("

") + .and_then(|s| s.strip_suffix("

")) + .unwrap_or(trimmed) + .trim() + .to_string() +} + fn extract_section(md: &str, heading: &str) -> Option { let mut out = String::new(); let mut capturing = false; diff --git a/crates/mantra-ui/Cargo.toml b/crates/mantra-ui/Cargo.toml index 017aec3..8a4f030 100644 --- a/crates/mantra-ui/Cargo.toml +++ b/crates/mantra-ui/Cargo.toml @@ -26,14 +26,22 @@ web-sys = { version = "0.3", optional = true, features = [ "DomTokenList", "Event", "EventTarget", + "KeyboardEvent", "Selection", "Range", "Node", "DomRect", + "Location", "Navigator", "Clipboard", + "IntersectionObserver", + "IntersectionObserverEntry", + "ScrollIntoViewOptions", + "ScrollBehavior", + "ScrollLogicalPosition", "console", ] } +js-sys = { version = "0.3", optional = true } # Server-only — pulled in under ssr feature for notes I/O + Claude HTTP. tokio = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } @@ -46,6 +54,7 @@ hydrate = [ "dep:console_error_panic_hook", "dep:wasm-bindgen", "dep:web-sys", + "dep:js-sys", ] ssr = [ "leptos/ssr", diff --git a/crates/mantra-ui/src/corpus.rs b/crates/mantra-ui/src/corpus.rs index 79321f2..c217625 100644 --- a/crates/mantra-ui/src/corpus.rs +++ b/crates/mantra-ui/src/corpus.rs @@ -111,6 +111,11 @@ pub struct Source { pub author: String, /// The core-claim one-liner (extracted from `## Core claim` section). pub core_claim: String, + /// HTML-rendered core claim — markdown emphasis (`*italic*`, `**bold**`) + /// converted to tags. Plain `core_claim` kept for places where text + /// only is wanted (meta descriptions, etc). + #[serde(default)] + pub core_claim_html: String, /// Tags from YAML frontmatter. pub tags: Vec, /// Confidence level (high / medium / low) from frontmatter. diff --git a/crates/mantra-ui/src/pages/landing.rs b/crates/mantra-ui/src/pages/landing.rs index e5e3117..b3476db 100644 --- a/crates/mantra-ui/src/pages/landing.rs +++ b/crates/mantra-ui/src/pages/landing.rs @@ -48,14 +48,28 @@ pub fn Landing() -> impl IntoView { Lang::En => "themes", }; + let scroll_hint = move || match lang.get() { + Lang::Ru => "↓ корпус ниже", + Lang::En => "↓ corpus below", + }; + let catalog_eyebrow = move || match lang.get() { + Lang::Ru => "корпус", + Lang::En => "corpus", + }; + view! {
-
+ +

{hero_title}

{hero_subtitle}

{hero_question}

-
+ {scroll_hint} + + +
+
{catalog_eyebrow}
"…"

}> {move || { @@ -63,7 +77,17 @@ pub fn Landing() -> impl IntoView { let lang_val = lang.get(); match res { Ok(LandingData { cycles, artifacts }) => { + // Featured work: pick one by day-of-year + // across all cycles' ordered sources. + let featured = pick_featured(&cycles); view! { + {featured.map(|f| view! { + + })} + {if !artifacts.is_empty() { let art_label = artifacts_label(); view! { @@ -104,6 +128,7 @@ pub fn Landing() -> impl IntoView { }) }}
+
} } @@ -203,6 +228,39 @@ fn CycleSection( } } +/// Pick one "featured" source. Deterministic — first source in first +/// non-empty cycle — so SSR and hydrate agree. Day-of-visit rotation +/// can be layered on later as a post-hydrate effect. +fn pick_featured(cycles: &[CycleSnapshot]) -> Option { + for c in cycles { + for slug in &c.order { + if let Some(s) = c.sources.iter().find(|s| &s.slug == slug) { + return Some(s.clone()); + } + } + } + None +} + +#[component] +fn FeaturedWork(source: Source, lang: Lang) -> impl IntoView { + let href = format!("/source/{}?lang={}", source.slug, lang.as_str()); + let label = move || match lang { + Lang::Ru => "сегодня", + Lang::En => "today", + }; + view! { + + } +} + #[component] fn WorkLine(source: Option, slug: String, lang: Lang) -> impl IntoView { let href = format!("/source/{slug}?lang={}", lang.as_str()); @@ -214,7 +272,7 @@ fn WorkLine(source: Option, slug: String, lang: Lang) -> impl IntoView { {s.author} " · " {s.title} -
{s.core_claim}
+
}.into_any(), diff --git a/crates/mantra-ui/src/pages/source.rs b/crates/mantra-ui/src/pages/source.rs index a7deb0d..83534bd 100644 --- a/crates/mantra-ui/src/pages/source.rs +++ b/crates/mantra-ui/src/pages/source.rs @@ -35,6 +35,8 @@ pub fn SourcePage() -> impl IntoView { let (anchor, set_anchor) = signal::>(None); let (tab, set_tab) = signal(Tab::Note); let (popover_pos, set_popover_pos) = signal::>(None); + let (focus_mode, set_focus_mode) = signal(false); + let (progress, set_progress) = signal(0.0_f32); let notes_tick = RwSignal::new(0u32); let slug_sig: Signal = Signal::derive(move || slug.get()); @@ -67,11 +69,29 @@ pub fn SourcePage() -> impl IntoView { } }); }); + + // Install keyboard shortcuts + scroll progress + dwell tracker + // once on mount. These listeners live for the document's lifetime + // on this page; route changes unmount this component and the + // closures with it. + let slug_for_kb = slug_sig; + Effect::new(move |_| { + install_keyboard_shortcuts( + set_focus_mode, + set_anchor, + set_popover_pos, + ); + install_scroll_tracker(set_progress); + install_dwell_tracker(slug_for_kb.get_untracked()); + }); } view! { -
+
"…"

}> {move || { data.get().map(|res| { @@ -119,7 +139,7 @@ pub fn SourcePage() -> impl IntoView {
{cycle.subtitle}

{src.title}

{(!src.core_claim.is_empty()).then(|| view! { -

{src.core_claim}

+

})}
{(!conf_label.is_empty()).then(|| view! { @@ -199,9 +219,234 @@ pub fn SourcePage() -> impl IntoView { set_tab=set_tab lang=lang_sig /> + + // Reading progress thread along the bottom edge +
+ +
+ + // Focus mode indicator (small corner pill, shows when active) + + + } } +// --- keyboard / scroll / dwell wiring ------------------------------ + +#[cfg(feature = "hydrate")] +fn install_keyboard_shortcuts( + set_focus_mode: WriteSignal, + set_anchor: WriteSignal>, + set_popover_pos: WriteSignal>, +) { + use wasm_bindgen::JsCast; + use wasm_bindgen::closure::Closure; + let Some(win) = web_sys::window() else { return }; + let Some(doc) = win.document() else { return }; + + let handler = Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| { + // Don't hijack typing in inputs/textareas + if let Some(target) = ev.target() { + if let Ok(el) = target.dyn_into::() { + let tag = el.tag_name(); + if tag == "INPUT" || tag == "TEXTAREA" { + return; + } + } + } + let key = ev.key(); + match key.as_str() { + "j" | "J" => { ev.prevent_default(); scroll_to_adjacent_paragraph(1); } + "k" | "K" => { ev.prevent_default(); scroll_to_adjacent_paragraph(-1); } + "f" | "F" => { + ev.prevent_default(); + set_focus_mode.update(|v| *v = !*v); + } + "h" | "H" => { + ev.prevent_default(); + if let Some(win) = web_sys::window() { + let _ = win.location().set_href("/"); + } + } + "Escape" => { + set_anchor.set(None); + set_popover_pos.set(None); + set_focus_mode.set(false); + } + _ => {} + } + }) as Box); + + let _ = doc.add_event_listener_with_callback("keydown", handler.as_ref().unchecked_ref()); + handler.forget(); +} + +#[cfg(not(feature = "hydrate"))] +fn install_keyboard_shortcuts( + _: WriteSignal, + _: WriteSignal>, + _: WriteSignal>, +) {} + +#[cfg(feature = "hydrate")] +fn scroll_to_adjacent_paragraph(delta: i32) { + 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 }; + + let viewport_h = win.inner_height().ok() + .and_then(|v| v.as_f64()) + .unwrap_or(800.0); + let threshold = viewport_h * 0.35; + + // Find the paragraph currently nearest the threshold line + let mut current_idx: i32 = 0; + for i in 0..nodes.length() { + let Some(node) = nodes.item(i) else { continue }; + let Ok(el) = node.dyn_into::() else { continue }; + let rect = el.get_bounding_client_rect(); + if rect.top() < threshold { + current_idx = i as i32; + } else { + break; + } + } + let target_idx = (current_idx + delta).max(0).min(nodes.length() as i32 - 1); + let Some(target) = nodes.item(target_idx as u32) else { return }; + let Ok(el) = target.dyn_into::() else { return }; + let opts = web_sys::ScrollIntoViewOptions::new(); + opts.set_behavior(web_sys::ScrollBehavior::Smooth); + opts.set_block(web_sys::ScrollLogicalPosition::Center); + el.scroll_into_view_with_scroll_into_view_options(&opts); +} + +#[cfg(not(feature = "hydrate"))] +fn scroll_to_adjacent_paragraph(_: i32) {} + +#[cfg(feature = "hydrate")] +fn install_scroll_tracker(set_progress: WriteSignal) { + use wasm_bindgen::JsCast; + use wasm_bindgen::closure::Closure; + let Some(win) = web_sys::window() else { return }; + + let handler = Closure::wrap(Box::new(move || { + let Some(win) = web_sys::window() else { return }; + let Some(doc) = win.document() else { return }; + let Some(el) = doc.document_element() else { return }; + let scroll_top = el.scroll_top() as f32; + let scroll_height = el.scroll_height() as f32; + let client_height = el.client_height() as f32; + let scrollable = (scroll_height - client_height).max(1.0); + let ratio = (scroll_top / scrollable).clamp(0.0, 1.0); + set_progress.set(ratio); + }) as Box); + + let _ = win.add_event_listener_with_callback("scroll", handler.as_ref().unchecked_ref()); + handler.forget(); +} + +#[cfg(not(feature = "hydrate"))] +fn install_scroll_tracker(_: WriteSignal) {} + +/// Dwell tracker — paragraphs a reader spends ≥12 seconds looking at +/// earn a subtle `.dwelled` marker. Stored in localStorage as +/// `mantra.dwell.` → comma-separated para-ids. Per-device, +/// per-user, invisible to anyone else. +#[cfg(feature = "hydrate")] +fn install_dwell_tracker(slug: String) { + use wasm_bindgen::JsCast; + use wasm_bindgen::closure::Closure; + use std::rc::Rc; + use std::cell::RefCell; + use std::collections::HashMap; + + let Some(win) = web_sys::window() else { return }; + let Some(doc) = win.document() else { return }; + + // Load previously-dwelled paragraphs for this slug + let storage_key = format!("mantra.dwell.{slug}"); + let mut dwelled: std::collections::HashSet = std::collections::HashSet::new(); + if let Ok(Some(storage)) = win.local_storage() { + if let Ok(Some(raw)) = storage.get_item(&storage_key) { + for id in raw.split(',') { + if !id.is_empty() { dwelled.insert(id.to_string()); } + } + } + } + + // Paint existing marks + for id in &dwelled { + let sel = format!("[data-para-id=\"{}\"]", id); + if let Ok(Some(node)) = doc.query_selector(&sel) { + let _ = node.class_list().add_1("dwelled"); + } + } + + // Track visibility start-times per paragraph + let visible: Rc>> = Rc::new(RefCell::new(HashMap::new())); + let dwelled = Rc::new(RefCell::new(dwelled)); + let slug_for_cb = slug.clone(); + let storage_key_for_cb = storage_key.clone(); + + let visible_clone = visible.clone(); + let dwelled_clone = dwelled.clone(); + let callback = Closure::wrap(Box::new(move |entries: js_sys::Array| { + let now = js_sys::Date::now(); + for i in 0..entries.length() { + let entry: web_sys::IntersectionObserverEntry = entries.get(i).unchecked_into(); + let Ok(target) = entry.target().dyn_into::() else { continue }; + let Some(id) = target.get_attribute("data-para-id") else { continue }; + if entry.is_intersecting() && entry.intersection_ratio() > 0.5 { + visible_clone.borrow_mut().entry(id.clone()).or_insert(now); + } else { + if let Some(started) = visible_clone.borrow_mut().remove(&id) { + let dwell_ms = now - started; + if dwell_ms >= 12_000.0 { + let mut set = dwelled_clone.borrow_mut(); + if set.insert(id.clone()) { + let _ = target.class_list().add_1("dwelled"); + // Persist + if let Some(win) = web_sys::window() { + if let Ok(Some(storage)) = win.local_storage() { + let joined: Vec = set.iter().cloned().collect(); + let _ = storage.set_item(&storage_key_for_cb, &joined.join(",")); + } + } + } + } + } + } + } + let _ = slug_for_cb; + }) as Box); + + let Ok(observer) = web_sys::IntersectionObserver::new(callback.as_ref().unchecked_ref()) else { return }; + callback.forget(); + + if let Ok(nodes) = doc.query_selector_all("[data-para-id]") { + for i in 0..nodes.length() { + let Some(node) = nodes.item(i) else { continue }; + if let Ok(el) = node.dyn_into::() { + observer.observe(&el); + } + } + } + let _ = visible; + let _ = dwelled; +} + +#[cfg(not(feature = "hydrate"))] +fn install_dwell_tracker(_: String) {} + /// Climb up from the click target to find the nearest `[data-para-id]` /// element; return its id + plain-text content (used as fallback anchor /// when no selection is active). diff --git a/sass/main.scss b/sass/main.scss index 050b10c..20f9f96 100644 --- a/sass/main.scss +++ b/sass/main.scss @@ -37,13 +37,15 @@ // --- Color tokens ---------------------------------------------------- :root { - // Pergament (light) + // Pergament (light). Warm paper + cooler ink for thermal tension: + // the page is lit, the letters carry the shadow. --mantra-bg: #fdfbf5; - --mantra-fg: #1c1917; - --mantra-muted: #78716c; + --mantra-bg-deep: #f5f0e3; + --mantra-fg: #181c22; + --mantra-muted: #6e6f70; --mantra-faint: #d6d3d0; --mantra-accent: #5e4b3a; - --mantra-hairline: rgba(28, 25, 23, 0.08); + --mantra-hairline: rgba(24, 28, 34, 0.08); --mantra-measure: 62ch; --mantra-serif: "Fraunces", Georgia, "Times New Roman", serif; @@ -114,6 +116,44 @@ main { max-width: 74ch; } +// Silent opening — first screen, room for a breath before the catalog. +// Holds the hero title + the root question + a small scroll-hint, then +// the catalog unfolds below. +.landing-opening { + min-height: calc(100vh - 4rem); + display: flex; + flex-direction: column; + justify-content: center; + padding: 2rem 0 3rem; + position: relative; +} +.landing-scroll-hint { + position: absolute; + bottom: 3vh; + left: 0; + font-family: var(--mantra-sans); + font-size: 0.68rem; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--mantra-faint); + font-variation-settings: "wght" 450; +} +.landing-catalog { + padding-top: 2rem; + position: relative; +} +.landing-catalog-label { + font-family: var(--mantra-sans); + font-variation-settings: "wght" 500; + font-size: 0.68rem; + letter-spacing: 0.28em; + text-transform: uppercase; + color: var(--mantra-muted); + margin: 0 0 3rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--mantra-hairline); +} + .landing-head { margin-bottom: 5rem; } .landing-title { @@ -332,7 +372,14 @@ main { color: var(--mantra-muted); line-height: 1.5; margin-top: 0.3rem; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; } +.work-claim em { font-style: italic; } +.work-claim strong { font-variation-settings: "opsz" 14, "SOFT" 30, "WONK" 0, "wght" 600; } .theme-line a { display: block; @@ -476,19 +523,21 @@ main { margin: 3.5rem 0 1.25rem; position: relative; } - @media (min-width: 1100px) { + @media (min-width: 1300px) { h2 { position: absolute; // Pull the label into the left gutter, outside the article box. - // 16ch for the label + 2.5rem breathing room before the text - // column (which starts at the article's padding-left, i.e. 0). - left: calc(-16ch - 2.5rem); + // Column is 20ch wide (fits long compound labels like + // «интеллектуальная родословная» in two lines) + 3rem breathing + // room before the paragraph column. + left: calc(-20ch - 3rem); margin: 0; - padding-top: 0.5rem; - width: 16ch; + padding-top: 0.55rem; + width: 20ch; text-align: right; + letter-spacing: 0.18em; + line-height: 1.35; } - // Insert spacing where H2 would have been inline h2 + * { margin-top: 3rem; } } @@ -1187,3 +1236,231 @@ main { to { transform: translateY(0); opacity: 1; } } } + + +// --- v0.4 polish: focus mode, reading thread, dwell trace ------------ + +// Focus mode: hides chrome, widens reading area, fades the inspector. +// Toggled by F or the "focus·f" corner button. +.reader-frame.focus-mode { + .lang-toggle, + .source-breadcrumb, + .source-foot { + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease-out; + } + .inspector { + transform: translateX(calc(100% - 2rem)); + transition: transform 0.35s cubic-bezier(0.2, 0.7, 0.25, 1); + opacity: 0.3; + } + .inspector:hover { opacity: 1; transform: translateX(0); } +} +@media (min-width: 1180px) { + .reader-frame.focus-mode { padding-right: 0; } + .reader-frame.focus-mode > main.source { max-width: 68ch; } +} + +// Focus-exit corner pill — only appears when focus-mode is on. +.focus-exit { + position: fixed; + bottom: 2rem; + right: 2rem; + z-index: 25; + background: var(--mantra-fg); + color: var(--mantra-bg); + border: none; + border-radius: 18px; + padding: 0.45rem 0.95rem; + font-family: var(--mantra-sans); + font-size: 0.7rem; + letter-spacing: 0.14em; + text-transform: lowercase; + font-variation-settings: "wght" 500; + cursor: pointer; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18); + transition: transform 0.15s; + animation: focus-exit-in 0.25s ease-out; +} +.focus-exit:hover { transform: translateY(-2px); } +@keyframes focus-exit-in { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +// Reading progress thread — 2px line along bottom, scales horizontally +// as the reader scrolls through the source. +.reading-thread { + position: fixed; + left: 0; + right: 0; + bottom: 0; + height: 2px; + z-index: 20; + background: transparent; + pointer-events: none; +} +.reading-thread-fill { + display: block; + height: 100%; + width: 100%; + background: var(--mantra-accent); + transform-origin: left center; + transform: scaleX(0); + transition: transform 0.18s ease-out; + opacity: 0.7; +} + +// Dwell trace — reader spent ≥12s on this paragraph; a small marker +// shows up in the margin. Personal, stored in localStorage. +// Distinct from .has-notes (public): dwell is the readers trace. +.source-body p[data-para-id].dwelled { + &::after { + content: ""; + position: absolute; + right: -1.25rem; + top: 0.75em; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--mantra-muted); + opacity: 0.4; + transition: opacity 0.3s, transform 0.3s; + } + &:hover::after { opacity: 0.7; transform: scale(1.3); } +} +@media (max-width: 900px) { + .source-body p[data-para-id].dwelled::after { display: none; } +} + + + +// --- v0.4.1 push-to-100: staggered hero, heavier artifact, focus anim --- + +// Staggered reveal — source hero unfolds сверху-вниз, не вспышкой +.source-hero > * { + opacity: 0; + transform: translateY(6px); + animation: hero-rise 0.6s cubic-bezier(0.2, 0.7, 0.25, 1) forwards; +} +.source-hero > *:nth-child(1) { animation-delay: 0.08s; } +.source-hero > *:nth-child(2) { animation-delay: 0.22s; } +.source-hero > *:nth-child(3) { animation-delay: 0.42s; } +.source-hero > *:nth-child(4) { animation-delay: 0.56s; } +.source-hero > *:nth-child(5) { animation-delay: 0.72s; } + +.source-body { + opacity: 0; + animation: body-rise 0.7s ease-out 0.9s forwards; +} + +@keyframes hero-rise { + to { opacity: 1; transform: translateY(0); } +} +@keyframes body-rise { + to { opacity: 1; } +} + +// Respect prefers-reduced-motion +@media (prefers-reduced-motion: reduce) { + .source-hero > *, .source-body { + animation: none; + opacity: 1; + transform: none; + } +} + +// Artifact typography — тяжелее, плотнее, визуально весомее чем distillation. +// Напряжение между регистрами: distillate — лёгкая живая нота; artifact — +// первая кость, которую держат. +.source.artifact { + .source-hero-title { + font-variation-settings: "opsz" 144, "SOFT" 30, "WONK" 0, "wght" 480; + letter-spacing: -0.022em; + } + .source-body { + font-variation-settings: "opsz" 18, "SOFT" 20, "WONK" 0, "wght" 430; + line-height: 1.72; + } + .source-body h2 { + font-variation-settings: "wght" 600; + letter-spacing: 0.22em; + } + .source-body strong { + font-variation-settings: "opsz" 18, "SOFT" 20, "WONK" 0, "wght" 680; + } +} + +// Focus mode — при входе выдох «тушения комнаты»: фон на мгновение +// темнеет до deep-pergament, chrome выдыхает, затем фон возвращается. +.reader-frame.focus-mode { + animation: room-dim 0.6s ease-out; +} +@keyframes room-dim { + 0% { background: var(--mantra-bg); } + 40% { background: var(--mantra-bg-deep); } + 100% { background: var(--mantra-bg); } +} +@media (prefers-reduced-motion: reduce) { + .reader-frame.focus-mode { animation: none; } +} + + +// Featured work — «сегодня» one source in focus, rotates by day. The +// rest of the corpus is shown below, but this one карта has typographic +// weight that others don't. Principle 5 (граница) — ONE thing in focus, +// the 33 others are available but not shown with the same gravity. +.featured-work { + margin-bottom: 5rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--mantra-hairline); +} +.featured-label { + font-family: var(--mantra-sans); + font-variation-settings: "wght" 500; + font-size: 0.7rem; + letter-spacing: 0.28em; + text-transform: uppercase; + color: var(--mantra-accent); + margin-bottom: 1.5rem; +} +.featured-card { + display: block; + text-decoration: none; + border-bottom: none; + padding: 0; + transition: transform 0.25s cubic-bezier(0.2, 0.7, 0.25, 1); +} +.featured-card:hover { transform: translateX(2px); } +.featured-author { + font-family: var(--mantra-serif); + font-variation-settings: "opsz" 24, "SOFT" 30, "WONK" 0, "wght" 520; + font-size: 1.05rem; + color: var(--mantra-fg); + margin-bottom: 0.4rem; +} +.featured-title { + font-family: var(--mantra-serif); + font-variation-settings: "opsz" 72, "SOFT" 50, "WONK" 0, "wght" 420; + font-size: clamp(1.6rem, 3vw, 2.2rem); + line-height: 1.15; + letter-spacing: -0.012em; + margin: 0 0 1.3rem; + color: var(--mantra-fg); +} +.featured-claim { + font-family: var(--mantra-serif); + font-style: italic; + font-variation-settings: "opsz" 24, "SOFT" 30, "WONK" 0, "wght" 400; + font-size: 1.05rem; + line-height: 1.55; + color: var(--mantra-muted); + max-width: 58ch; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; +} +.featured-claim em { font-style: italic; color: var(--mantra-fg); } +.featured-claim strong { font-variation-settings: "opsz" 24, "SOFT" 30, "WONK" 0, "wght" 600; color: var(--mantra-fg); }