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:
parent
d686692b1a
commit
eebc876216
7 changed files with 631 additions and 16 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1419,6 +1419,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"chrono",
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
"leptos",
|
||||
"leptos_meta",
|
||||
"leptos_router",
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ fn parse_source(path: &Path, cycle_slug: &str) -> Result<Source> {
|
|||
.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<Source> {
|
|||
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<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> {
|
||||
let mut out = String::new();
|
||||
let mut capturing = false;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// Confidence level (high / medium / low) from frontmatter.
|
||||
|
|
|
|||
|
|
@ -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! {
|
||||
<main class="landing">
|
||||
<LangToggle current=lang/>
|
||||
<header class="landing-head">
|
||||
|
||||
<section class="landing-opening">
|
||||
<h1 class="landing-title">{hero_title}</h1>
|
||||
<p class="landing-subtitle">{hero_subtitle}</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> }>
|
||||
{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! {
|
||||
<FeaturedWork
|
||||
source=f
|
||||
lang=lang_val
|
||||
/>
|
||||
})}
|
||||
|
||||
{if !artifacts.is_empty() {
|
||||
let art_label = artifacts_label();
|
||||
view! {
|
||||
|
|
@ -104,6 +128,7 @@ pub fn Landing() -> impl IntoView {
|
|||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</div>
|
||||
</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]
|
||||
fn WorkLine(source: Option<Source>, slug: String, lang: Lang) -> impl IntoView {
|
||||
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-sep">" · "</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>
|
||||
</li>
|
||||
}.into_any(),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ pub fn SourcePage() -> impl IntoView {
|
|||
let (anchor, set_anchor) = signal::<Option<Anchor>>(None);
|
||||
let (tab, set_tab) = signal(Tab::Note);
|
||||
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 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! {
|
||||
<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> }>
|
||||
{move || {
|
||||
data.get().map(|res| {
|
||||
|
|
@ -119,7 +139,7 @@ pub fn SourcePage() -> impl IntoView {
|
|||
<div class="source-hero-eyebrow">{cycle.subtitle}</div>
|
||||
<h1 class="source-hero-title">{src.title}</h1>
|
||||
{(!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">
|
||||
{(!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
|
||||
<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]`
|
||||
/// element; return its id + plain-text content (used as fallback anchor
|
||||
/// when no selection is active).
|
||||
|
|
|
|||
299
sass/main.scss
299
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); }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue