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 = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
|
"js-sys",
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_meta",
|
"leptos_meta",
|
||||||
"leptos_router",
|
"leptos_router",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
299
sass/main.scss
299
sass/main.scss
|
|
@ -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); }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue