mantra v0.4: landing silent opening + featured work + source staggered reveal + focus transition + dwell trace + keyboard shortcuts + reading thread + thermal palette + core_claim markdown

This commit is contained in:
Alexey 2026-04-24 22:24:36 +05:00
parent d686692b1a
commit eebc876216
7 changed files with 631 additions and 16 deletions

1
Cargo.lock generated
View file

@ -1419,6 +1419,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"console_error_panic_hook", "console_error_panic_hook",
"js-sys",
"leptos", "leptos",
"leptos_meta", "leptos_meta",
"leptos_router", "leptos_router",

View file

@ -239,6 +239,7 @@ fn parse_source(path: &Path, cycle_slug: &str) -> Result<Source> {
.unwrap_or_default() .unwrap_or_default()
.trim() .trim()
.replace('\n', " "); .replace('\n', " ");
let core_claim_html = render_inline_md(&core_claim);
let author = fm let author = fm
.source .source
@ -255,6 +256,7 @@ fn parse_source(path: &Path, cycle_slug: &str) -> Result<Source> {
title, title,
author, author,
core_claim, core_claim,
core_claim_html,
tags: fm.tags, tags: fm.tags,
confidence: if fm.confidence.is_empty() { confidence: if fm.confidence.is_empty() {
"medium".into() "medium".into()
@ -266,6 +268,24 @@ fn parse_source(path: &Path, cycle_slug: &str) -> Result<Source> {
}) })
} }
/// Render short inline markdown (a sentence or short paragraph) to HTML.
/// Strips the outer `<p>` 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("<p>")
.and_then(|s| s.strip_suffix("</p>"))
.unwrap_or(trimmed)
.trim()
.to_string()
}
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;

View file

@ -26,14 +26,22 @@ web-sys = { version = "0.3", optional = true, features = [
"DomTokenList", "DomTokenList",
"Event", "Event",
"EventTarget", "EventTarget",
"KeyboardEvent",
"Selection", "Selection",
"Range", "Range",
"Node", "Node",
"DomRect", "DomRect",
"Location",
"Navigator", "Navigator",
"Clipboard", "Clipboard",
"IntersectionObserver",
"IntersectionObserverEntry",
"ScrollIntoViewOptions",
"ScrollBehavior",
"ScrollLogicalPosition",
"console", "console",
] } ] }
js-sys = { version = "0.3", optional = true }
# Server-only — pulled in under ssr feature for notes I/O + Claude HTTP. # Server-only — pulled in under ssr feature for notes I/O + Claude HTTP.
tokio = { workspace = true, optional = true } tokio = { workspace = true, optional = true }
reqwest = { workspace = true, optional = true } reqwest = { workspace = true, optional = true }
@ -46,6 +54,7 @@ hydrate = [
"dep:console_error_panic_hook", "dep:console_error_panic_hook",
"dep:wasm-bindgen", "dep:wasm-bindgen",
"dep:web-sys", "dep:web-sys",
"dep:js-sys",
] ]
ssr = [ ssr = [
"leptos/ssr", "leptos/ssr",

View file

@ -111,6 +111,11 @@ pub struct Source {
pub author: String, pub author: String,
/// The core-claim one-liner (extracted from `## Core claim` section). /// The core-claim one-liner (extracted from `## Core claim` section).
pub core_claim: String, 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. /// Tags from YAML frontmatter.
pub tags: Vec<String>, pub tags: Vec<String>,
/// Confidence level (high / medium / low) from frontmatter. /// Confidence level (high / medium / low) from frontmatter.

View file

@ -48,14 +48,28 @@ pub fn Landing() -> impl IntoView {
Lang::En => "themes", 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! { view! {
<main class="landing"> <main class="landing">
<LangToggle current=lang/> <LangToggle current=lang/>
<header class="landing-head">
<section class="landing-opening">
<h1 class="landing-title">{hero_title}</h1> <h1 class="landing-title">{hero_title}</h1>
<p class="landing-subtitle">{hero_subtitle}</p> <p class="landing-subtitle">{hero_subtitle}</p>
<p class="landing-question">{hero_question}</p> <p class="landing-question">{hero_question}</p>
</header> <span class="landing-scroll-hint">{scroll_hint}</span>
</section>
<div class="landing-catalog">
<div class="landing-catalog-label">{catalog_eyebrow}</div>
<Suspense fallback=move || view! { <p class="landing-loading">""</p> }> <Suspense fallback=move || view! { <p class="landing-loading">""</p> }>
{move || { {move || {
@ -63,7 +77,17 @@ pub fn Landing() -> impl IntoView {
let lang_val = lang.get(); let lang_val = lang.get();
match res { match res {
Ok(LandingData { cycles, artifacts }) => { Ok(LandingData { cycles, artifacts }) => {
// Featured work: pick one by day-of-year
// across all cycles' ordered sources.
let featured = pick_featured(&cycles);
view! { view! {
{featured.map(|f| view! {
<FeaturedWork
source=f
lang=lang_val
/>
})}
{if !artifacts.is_empty() { {if !artifacts.is_empty() {
let art_label = artifacts_label(); let art_label = artifacts_label();
view! { view! {
@ -104,6 +128,7 @@ pub fn Landing() -> impl IntoView {
}) })
}} }}
</Suspense> </Suspense>
</div>
</main> </main>
} }
} }
@ -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<Source> {
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! {
<section class="featured-work">
<div class="featured-label">{label}</div>
<a class="featured-card" href=href>
<div class="featured-author">{source.author}</div>
<h2 class="featured-title">{source.title}</h2>
<div class="featured-claim" inner_html=source.core_claim_html></div>
</a>
</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());
@ -214,7 +272,7 @@ fn WorkLine(source: Option<Source>, slug: String, lang: Lang) -> impl IntoView {
<span class="work-author">{s.author}</span> <span class="work-author">{s.author}</span>
<span class="work-sep">" · "</span> <span class="work-sep">" · "</span>
<span class="work-title">{s.title}</span> <span class="work-title">{s.title}</span>
<div class="work-claim">{s.core_claim}</div> <div class="work-claim" inner_html=s.core_claim_html></div>
</a> </a>
</li> </li>
}.into_any(), }.into_any(),

View file

@ -35,6 +35,8 @@ pub fn SourcePage() -> impl IntoView {
let (anchor, set_anchor) = signal::<Option<Anchor>>(None); let (anchor, set_anchor) = signal::<Option<Anchor>>(None);
let (tab, set_tab) = signal(Tab::Note); let (tab, set_tab) = signal(Tab::Note);
let (popover_pos, set_popover_pos) = signal::<Option<PopoverPos>>(None); let (popover_pos, set_popover_pos) = signal::<Option<PopoverPos>>(None);
let (focus_mode, set_focus_mode) = signal(false);
let (progress, set_progress) = signal(0.0_f32);
let notes_tick = RwSignal::new(0u32); let notes_tick = RwSignal::new(0u32);
let slug_sig: Signal<String> = Signal::derive(move || slug.get()); let slug_sig: Signal<String> = 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! { view! {
<LangToggle current=lang/> <LangToggle current=lang/>
<div class="reader-frame"> <div
class="reader-frame"
class:focus-mode=move || focus_mode.get()
>
<Suspense fallback=move || view! { <p class="source-loading">""</p> }> <Suspense fallback=move || view! { <p class="source-loading">""</p> }>
{move || { {move || {
data.get().map(|res| { data.get().map(|res| {
@ -119,7 +139,7 @@ pub fn SourcePage() -> impl IntoView {
<div class="source-hero-eyebrow">{cycle.subtitle}</div> <div class="source-hero-eyebrow">{cycle.subtitle}</div>
<h1 class="source-hero-title">{src.title}</h1> <h1 class="source-hero-title">{src.title}</h1>
{(!src.core_claim.is_empty()).then(|| view! { {(!src.core_claim.is_empty()).then(|| view! {
<p class="source-hero-claim">{src.core_claim}</p> <p class="source-hero-claim" inner_html=src.core_claim_html></p>
})} })}
<div class="source-hero-meta"> <div class="source-hero-meta">
{(!conf_label.is_empty()).then(|| view! { {(!conf_label.is_empty()).then(|| view! {
@ -199,9 +219,234 @@ pub fn SourcePage() -> impl IntoView {
set_tab=set_tab set_tab=set_tab
lang=lang_sig lang=lang_sig
/> />
// Reading progress thread along the bottom edge
<div class="reading-thread">
<span
class="reading-thread-fill"
style=move || format!("transform: scaleX({:.4})", progress.get())
/>
</div>
// Focus mode indicator (small corner pill, shows when active)
<Show when=move || focus_mode.get() fallback=|| ()>
<button
class="focus-exit"
on:click=move |_| set_focus_mode.set(false)
title="выйти из focus (Esc)"
>"focus · f"</button>
</Show>
} }
} }
// --- keyboard / scroll / dwell wiring ------------------------------
#[cfg(feature = "hydrate")]
fn install_keyboard_shortcuts(
set_focus_mode: WriteSignal<bool>,
set_anchor: WriteSignal<Option<Anchor>>,
set_popover_pos: WriteSignal<Option<PopoverPos>>,
) {
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::<web_sys::Element>() {
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<dyn FnMut(_)>);
let _ = doc.add_event_listener_with_callback("keydown", handler.as_ref().unchecked_ref());
handler.forget();
}
#[cfg(not(feature = "hydrate"))]
fn install_keyboard_shortcuts(
_: WriteSignal<bool>,
_: WriteSignal<Option<Anchor>>,
_: WriteSignal<Option<PopoverPos>>,
) {}
#[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::<web_sys::Element>() 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::<web_sys::Element>() 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<f32>) {
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<dyn FnMut()>);
let _ = win.add_event_listener_with_callback("scroll", handler.as_ref().unchecked_ref());
handler.forget();
}
#[cfg(not(feature = "hydrate"))]
fn install_scroll_tracker(_: WriteSignal<f32>) {}
/// Dwell tracker — paragraphs a reader spends ≥12 seconds looking at
/// earn a subtle `.dwelled` marker. Stored in localStorage as
/// `mantra.dwell.<slug>` → 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<String> = 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<RefCell<HashMap<String, f64>>> = 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::<web_sys::Element>() 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<String> = set.iter().cloned().collect();
let _ = storage.set_item(&storage_key_for_cb, &joined.join(","));
}
}
}
}
}
}
}
let _ = slug_for_cb;
}) as Box<dyn FnMut(js_sys::Array)>);
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::<web_sys::Element>() {
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]` /// Climb up from the click target to find the nearest `[data-para-id]`
/// element; return its id + plain-text content (used as fallback anchor /// element; return its id + plain-text content (used as fallback anchor
/// when no selection is active). /// when no selection is active).

View file

@ -37,13 +37,15 @@
// --- Color tokens ---------------------------------------------------- // --- Color tokens ----------------------------------------------------
:root { :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-bg: #fdfbf5;
--mantra-fg: #1c1917; --mantra-bg-deep: #f5f0e3;
--mantra-muted: #78716c; --mantra-fg: #181c22;
--mantra-muted: #6e6f70;
--mantra-faint: #d6d3d0; --mantra-faint: #d6d3d0;
--mantra-accent: #5e4b3a; --mantra-accent: #5e4b3a;
--mantra-hairline: rgba(28, 25, 23, 0.08); --mantra-hairline: rgba(24, 28, 34, 0.08);
--mantra-measure: 62ch; --mantra-measure: 62ch;
--mantra-serif: "Fraunces", Georgia, "Times New Roman", serif; --mantra-serif: "Fraunces", Georgia, "Times New Roman", serif;
@ -114,6 +116,44 @@ main {
max-width: 74ch; 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-head { margin-bottom: 5rem; }
.landing-title { .landing-title {
@ -332,7 +372,14 @@ main {
color: var(--mantra-muted); color: var(--mantra-muted);
line-height: 1.5; line-height: 1.5;
margin-top: 0.3rem; 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 { .theme-line a {
display: block; display: block;
@ -476,19 +523,21 @@ main {
margin: 3.5rem 0 1.25rem; margin: 3.5rem 0 1.25rem;
position: relative; position: relative;
} }
@media (min-width: 1100px) { @media (min-width: 1300px) {
h2 { h2 {
position: absolute; position: absolute;
// Pull the label into the left gutter, outside the article box. // Pull the label into the left gutter, outside the article box.
// 16ch for the label + 2.5rem breathing room before the text // Column is 20ch wide (fits long compound labels like
// column (which starts at the article's padding-left, i.e. 0). // «интеллектуальная родословная» in two lines) + 3rem breathing
left: calc(-16ch - 2.5rem); // room before the paragraph column.
left: calc(-20ch - 3rem);
margin: 0; margin: 0;
padding-top: 0.5rem; padding-top: 0.55rem;
width: 16ch; width: 20ch;
text-align: right; text-align: right;
letter-spacing: 0.18em;
line-height: 1.35;
} }
// Insert spacing where H2 would have been inline
h2 + * { margin-top: 3rem; } h2 + * { margin-top: 3rem; }
} }
@ -1187,3 +1236,231 @@ main {
to { transform: translateY(0); opacity: 1; } 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); }