mantra v0.3: selection popover + persistent inspector + source hero + landing polish

This commit is contained in:
Alexey 2026-04-24 20:09:14 +05:00
parent d501b1b700
commit d686692b1a
8 changed files with 1307 additions and 568 deletions

View file

@ -26,6 +26,12 @@ web-sys = { version = "0.3", optional = true, features = [
"DomTokenList", "DomTokenList",
"Event", "Event",
"EventTarget", "EventTarget",
"Selection",
"Range",
"Node",
"DomRect",
"Navigator",
"Clipboard",
"console", "console",
] } ] }
# 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.

View file

@ -1,9 +1,4 @@
//! `/artifact/:slug` — closing document of a cycle. //! `/artifact/:slug` — closing document of a cycle.
//!
//! Currently renders: manifest.md (cycle 1) and applied-principles.md
//! (cycle 2). Same book-grade typography as the source page, same
//! paragraph-id → margin drawer wiring so readers can annotate the
//! artifacts too.
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::hooks::{use_params, use_query_map}; use leptos_router::hooks::{use_params, use_query_map};
@ -11,7 +6,7 @@ use leptos_router::params::Params;
use crate::api::{fetch_artifact, ArtifactPageData}; use crate::api::{fetch_artifact, ArtifactPageData};
use crate::corpus::Lang; use crate::corpus::Lang;
use crate::pages::margin::{ActivePara, MarginDrawer}; use crate::pages::inspector::{Anchor, Inspector, PopoverPos, SelectionPopover, Tab};
use crate::pages::shared::LangToggle; use crate::pages::shared::LangToggle;
#[derive(Params, PartialEq, Clone, Debug)] #[derive(Params, PartialEq, Clone, Debug)]
@ -31,12 +26,20 @@ pub fn ArtifactPage() -> impl IntoView {
Lang::from_query(query.read().get("lang").as_deref()) Lang::from_query(query.read().get("lang").as_deref())
}); });
let (active, set_active) = signal::<Option<ActivePara>>(None); 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 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());
let lang_sig: Signal<Lang> = Signal::derive(move || lang.get()); let lang_sig: Signal<Lang> = Signal::derive(move || lang.get());
Effect::new(move |_| {
let _ = slug.get();
set_anchor.set(None);
set_popover_pos.set(None);
});
let data = Resource::new( let data = Resource::new(
move || (slug.get(), lang.get().as_str().to_string()), move || (slug.get(), lang.get().as_str().to_string()),
|(s, l)| fetch_artifact(s, l), |(s, l)| fetch_artifact(s, l),
@ -61,6 +64,7 @@ pub fn ArtifactPage() -> impl IntoView {
view! { view! {
<LangToggle current=lang/> <LangToggle current=lang/>
<div class="reader-frame">
<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| {
@ -83,9 +87,15 @@ pub fn ArtifactPage() -> impl IntoView {
<article <article
class="source-body" class="source-body"
inner_html=artifact.body_html inner_html=artifact.body_html
on:mouseup=move |ev| {
handle_mouseup(&ev, &set_popover_pos);
}
on:click=move |ev| { on:click=move |ev| {
if let Some(p) = paragraph_from_event(&ev) { if popover_pos.get_untracked().is_some() {
set_active.set(Some(p)); return;
}
if let Some(a) = paragraph_from_event(&ev) {
set_anchor.set(Some(a));
} }
} }
></article> ></article>
@ -111,18 +121,29 @@ pub fn ArtifactPage() -> impl IntoView {
}} }}
</Suspense> </Suspense>
<MarginDrawer <Inspector
slug=slug_sig slug=slug_sig
lang=lang_sig lang=lang_sig
active=active anchor=anchor
set_active=set_active set_anchor=set_anchor
tab=tab
set_tab=set_tab
notes_tick=notes_tick notes_tick=notes_tick
/> />
</div>
<SelectionPopover
pos=popover_pos
set_pos=set_popover_pos
set_anchor=set_anchor
set_tab=set_tab
lang=lang_sig
/>
} }
} }
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<ActivePara> { fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<Anchor> {
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
let target = ev.target()?; let target = ev.target()?;
let mut el = target.dyn_into::<web_sys::Element>().ok()?; let mut el = target.dyn_into::<web_sys::Element>().ok()?;
@ -131,14 +152,77 @@ fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<ActivePara> {
let id = el.get_attribute("data-para-id")?; let id = el.get_attribute("data-para-id")?;
let text = el.text_content().unwrap_or_default(); let text = el.text_content().unwrap_or_default();
let excerpt = text.trim().to_string(); let excerpt = text.trim().to_string();
return Some(ActivePara { id, excerpt }); return Some(Anchor {
para_id: id,
excerpt,
is_selection: false,
});
} }
el = el.parent_element()?; el = el.parent_element()?;
} }
} }
#[cfg(not(feature = "hydrate"))] #[cfg(not(feature = "hydrate"))]
fn paragraph_from_event(_ev: &leptos::ev::MouseEvent) -> Option<ActivePara> { fn paragraph_from_event(_ev: &leptos::ev::MouseEvent) -> Option<Anchor> {
None
}
#[cfg(feature = "hydrate")]
fn handle_mouseup(
_ev: &leptos::ev::MouseEvent,
set_pos: &WriteSignal<Option<PopoverPos>>,
) {
let Some(win) = web_sys::window() else { return };
let Ok(Some(sel)) = win.get_selection() else {
set_pos.set(None);
return;
};
if sel.is_collapsed() {
set_pos.set(None);
return;
}
let text = match sel.to_string().as_string() {
Some(s) => s.trim().to_string(),
None => { set_pos.set(None); return; }
};
if text.len() < 2 { set_pos.set(None); return; }
let Ok(range) = sel.get_range_at(0) else {
set_pos.set(None);
return;
};
let rect = range.get_bounding_client_rect();
let start_node = range.start_container().ok();
let para_id = start_node.as_ref().and_then(find_para_id_from_node);
let Some(para_id) = para_id else {
set_pos.set(None);
return;
};
set_pos.set(Some(PopoverPos {
x: rect.left() + rect.width() / 2.0,
y: rect.top() - 8.0,
anchor: Anchor { para_id, excerpt: text, is_selection: true },
}));
}
#[cfg(not(feature = "hydrate"))]
fn handle_mouseup(
_ev: &leptos::ev::MouseEvent,
_set_pos: &WriteSignal<Option<PopoverPos>>,
) {}
#[cfg(feature = "hydrate")]
fn find_para_id_from_node(node: &web_sys::Node) -> Option<String> {
use wasm_bindgen::JsCast;
let mut current: Option<web_sys::Element> = node
.dyn_ref::<web_sys::Element>()
.cloned()
.or_else(|| node.parent_element());
while let Some(el) = current {
if el.has_attribute("data-para-id") {
return el.get_attribute("data-para-id");
}
current = el.parent_element();
}
None None
} }

View file

@ -0,0 +1,485 @@
//! The right-side inspector panel — persistent, not modal.
//!
//! v0.3 replacement for the margin drawer. Two tabs: Ask (ask Claude
//! about current anchor) and Notes (list + add notes for this source).
//! Driven by an `anchor` signal that tracks the reader's focus:
//! selected text (via mouseup → getSelection) OR a whole paragraph
//! (fallback for touch / narrow / no-selection cases).
//!
//! Also exports `SelectionPopover` — the small floating pill that
//! appears above a text selection with quick actions.
use leptos::html;
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::api::{ask_claude, fetch_notes, save_note, NoteEntry, NoteKind};
use crate::corpus::Lang;
/// Anchor — the "thing the inspector is currently about." Either a
/// selection (is_selection=true; excerpt is the selected text) or a
/// whole paragraph (is_selection=false; excerpt is the para's text).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Anchor {
pub para_id: String,
pub excerpt: String,
pub is_selection: bool,
}
/// Popover position + anchor it describes.
#[derive(Debug, Clone)]
pub struct PopoverPos {
pub x: f64,
pub y: f64,
pub anchor: Anchor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Note,
Ask,
}
#[component]
pub fn Inspector(
slug: Signal<String>,
lang: Signal<Lang>,
anchor: ReadSignal<Option<Anchor>>,
set_anchor: WriteSignal<Option<Anchor>>,
tab: ReadSignal<Tab>,
set_tab: WriteSignal<Tab>,
/// Bumped when notes change, so the source page can re-annotate dots.
notes_tick: RwSignal<u32>,
) -> impl IntoView {
let (notes, set_notes) = signal::<Vec<NoteEntry>>(Vec::new());
let (draft, set_draft) = signal(String::new());
let (pending, set_pending) = signal(false);
let (error, set_error) = signal::<Option<String>>(None);
let (author, set_author) = signal(initial_author());
let textarea_ref: NodeRef<html::Textarea> = NodeRef::new();
let reload = move |slug_s: String| {
spawn_local(async move {
match fetch_notes(slug_s).await {
Ok(v) => {
set_notes.set(v);
notes_tick.update(|n| *n = n.wrapping_add(1));
}
Err(e) => {
log(&format!("fetch_notes error: {e}"));
}
}
});
};
// Reload when slug changes.
Effect::new(move |_| {
let s = slug.get();
if !s.is_empty() {
reload(s);
}
});
// Reset draft when anchor changes.
Effect::new(move |_| {
let _ = anchor.get();
set_draft.set(String::new());
set_error.set(None);
clear_textarea(&textarea_ref);
});
// Notes filtered to current anchor's paragraph.
let filtered = Memo::new(move |_| {
let a = anchor.get();
notes.with(|all| {
if let Some(a) = a {
all.iter().filter(|e| e.para_id == a.para_id).cloned().collect::<Vec<_>>()
} else {
all.clone()
}
})
});
let clear_anchor = move |_| set_anchor.set(None);
let submit = move || {
let Some(a) = anchor.get_untracked() else {
log("submit: no anchor");
return;
};
let text = draft.get_untracked().trim().to_string();
if text.is_empty() {
return;
}
let author_name = {
let a = author.get_untracked();
if a.trim().is_empty() { "anon".to_string() } else { a }
};
let slug_s = slug.get_untracked();
let para_id = a.para_id.clone();
let excerpt = a.excerpt.clone();
let current_tab = tab.get_untracked();
set_pending.set(true);
set_error.set(None);
spawn_local(async move {
let res = match current_tab {
Tab::Note => save_note(
slug_s.clone(), para_id, excerpt, text, author_name,
).await.map(|_| ()),
Tab::Ask => ask_claude(
slug_s.clone(), para_id, excerpt, text, author_name,
).await.map(|_| ()),
};
set_pending.set(false);
match res {
Ok(()) => {
set_draft.set(String::new());
clear_textarea(&textarea_ref);
reload(slug_s);
}
Err(e) => {
set_error.set(Some(format!("{e}")));
}
}
});
};
let label_note = move || match lang.get() { Lang::Ru => "заметка", Lang::En => "note" };
let label_ask = move || match lang.get() { Lang::Ru => "спросить", Lang::En => "ask" };
let placeholder_note = move || match lang.get() {
Lang::Ru => "запишите мысль…",
Lang::En => "write a thought…",
};
let placeholder_ask = move || match lang.get() {
Lang::Ru => "спросите Клода о пассаже…",
Lang::En => "ask Claude about the passage…",
};
let label_save = move || match lang.get() { Lang::Ru => "сохранить", Lang::En => "save" };
let label_ask_go = move || match lang.get() { Lang::Ru => "спросить", Lang::En => "ask" };
let label_pending_note = move || match lang.get() {
Lang::Ru => "сохраняю…",
Lang::En => "saving…",
};
let label_pending_ask = move || match lang.get() {
Lang::Ru => "думаю…",
Lang::En => "thinking…",
};
let label_you = move || match lang.get() { Lang::Ru => "вы:", Lang::En => "you:" };
let label_selection_hint = move || match lang.get() {
Lang::Ru => "выделите фрагмент или кликните параграф",
Lang::En => "select a fragment or click a paragraph",
};
let label_all_notes = move || match lang.get() {
Lang::Ru => "все заметки источника",
Lang::En => "all notes for this source",
};
let label_clear = move || match lang.get() {
Lang::Ru => "убрать якорь",
Lang::En => "clear anchor",
};
view! {
<aside class="inspector">
<header class="inspector-head">
<div class="inspector-tabs">
<button
class="tab"
class:tab-active=move || tab.get() == Tab::Note
on:click=move |_| set_tab.set(Tab::Note)
>{label_note}</button>
<button
class="tab"
class:tab-active=move || tab.get() == Tab::Ask
on:click=move |_| set_tab.set(Tab::Ask)
>{label_ask}</button>
</div>
</header>
<div class="inspector-anchor">
{move || match anchor.get() {
None => view! {
<p class="inspector-hint">{label_selection_hint}</p>
}.into_any(),
Some(a) => view! {
<div class="inspector-anchor-chip">
<div
class="inspector-excerpt"
class:is-selection=a.is_selection
>
{a.excerpt.clone()}
</div>
<button
class="inspector-anchor-clear"
on:click=clear_anchor
title=label_clear
aria-label=label_clear
>"×"</button>
</div>
}.into_any(),
}}
</div>
<div class="inspector-form">
<textarea
class="inspector-input"
node_ref=textarea_ref
placeholder=move || match tab.get() {
Tab::Note => placeholder_note(),
Tab::Ask => placeholder_ask(),
}
on:input=move |ev| set_draft.set(event_target_value(&ev))
rows="4"
/>
{move || error.get().map(|e| view! {
<p class="inspector-error">{e}</p>
})}
<div class="inspector-actions">
<label class="inspector-author">
<span class="inspector-author-label">{label_you}</span>
<input
type="text"
class="inspector-author-input"
prop:value=move || author.get()
on:input=move |ev| {
let v = event_target_value(&ev);
persist_author(&v);
set_author.set(v);
}
placeholder="anon"
/>
</label>
<button
type="button"
class="inspector-submit"
disabled=move || {
pending.get()
|| draft.get().trim().is_empty()
|| anchor.with(|a| a.is_none())
}
on:click=move |_| submit()
>
{move || {
if pending.get() {
match tab.get() {
Tab::Note => label_pending_note(),
Tab::Ask => label_pending_ask(),
}
} else {
match tab.get() {
Tab::Note => label_save(),
Tab::Ask => label_ask_go(),
}
}
}}
</button>
</div>
</div>
<div class="inspector-entries">
<div class="inspector-entries-label">
{move || match (anchor.get(), filtered.get().len()) {
(None, 0) => String::new(),
(None, n) => format!("{} · {}", label_all_notes(), n),
(Some(_), 0) => String::new(),
(Some(_), n) => format!("{n}"),
}}
</div>
{move || {
let list = filtered.get();
list.into_iter().map(|e| view! {
<NoteCard entry=e/>
}).collect_view()
}}
</div>
</aside>
}
}
// --- Selection popover ------------------------------------------------
#[component]
pub fn SelectionPopover(
pos: ReadSignal<Option<PopoverPos>>,
set_pos: WriteSignal<Option<PopoverPos>>,
set_anchor: WriteSignal<Option<Anchor>>,
set_tab: WriteSignal<Tab>,
lang: Signal<Lang>,
) -> impl IntoView {
let label_ask = move || match lang.get() { Lang::Ru => "Спросить", Lang::En => "Ask" };
let label_note = move || match lang.get() { Lang::Ru => "Заметка", Lang::En => "Note" };
let label_copy = move || match lang.get() { Lang::Ru => "Копировать", Lang::En => "Copy" };
let action_ask = move |_| {
if let Some(p) = pos.get_untracked() {
set_anchor.set(Some(p.anchor.clone()));
set_tab.set(Tab::Ask);
clear_browser_selection();
set_pos.set(None);
}
};
let action_note = move |_| {
if let Some(p) = pos.get_untracked() {
set_anchor.set(Some(p.anchor.clone()));
set_tab.set(Tab::Note);
clear_browser_selection();
set_pos.set(None);
}
};
let action_copy = move |_| {
if let Some(p) = pos.get_untracked() {
copy_to_clipboard(&p.anchor.excerpt);
set_pos.set(None);
}
};
let action_close = move |_| {
clear_browser_selection();
set_pos.set(None);
};
view! {
<Show
when=move || pos.get().is_some()
fallback=|| ().into_any()
>
{move || {
let p = pos.get().unwrap();
let style = format!(
"left: {}px; top: {}px;",
p.x,
p.y,
);
view! {
<div class="selpop" style=style>
<button class="selpop-btn selpop-ask" on:click=action_ask>
<span class="selpop-ico">""</span>
<span>{label_ask}</span>
</button>
<span class="selpop-sep"/>
<button class="selpop-btn" on:click=action_note>{label_note}</button>
<button class="selpop-btn" on:click=action_copy>{label_copy}</button>
<span class="selpop-sep"/>
<button class="selpop-btn selpop-close" on:click=action_close>"×"</button>
</div>
}
}}
</Show>
}
}
// --- Note card ---------------------------------------------------------
#[component]
fn NoteCard(entry: NoteEntry) -> impl IntoView {
let is_ask = entry.kind == NoteKind::Ask;
let cls = if is_ask { "note-card note-card-ask" } else { "note-card" };
let (q, a) = if is_ask {
split_ask_body(&entry.body)
} else {
(None, Some(entry.body.clone()))
};
view! {
<article class=cls>
<header class="note-card-meta">{entry.author_ts}</header>
{q.map(|s| view! { <p class="note-q">{s}</p> })}
{a.map(|s| view! { <p class="note-a">{s}</p> })}
</article>
}
}
fn split_ask_body(body: &str) -> (Option<String>, Option<String>) {
let mut q = None;
let mut a = None;
if let Some(q_rest) = body.strip_prefix("**Q:**") {
if let Some((q_part, a_part)) = q_rest.split_once("**A (Claude):**") {
q = Some(q_part.trim().to_string());
a = Some(a_part.trim().to_string());
} else {
q = Some(q_rest.trim().to_string());
}
} else {
a = Some(body.trim().to_string());
}
(q, a)
}
// --- Author (inline) --------------------------------------------------
fn initial_author() -> String {
#[cfg(target_arch = "wasm32")]
{
if let Some(win) = web_sys::window() {
if let Ok(Some(storage)) = win.local_storage() {
if let Ok(Some(name)) = storage.get_item("mantra.author") {
if !name.trim().is_empty() {
return name;
}
}
}
}
}
String::new()
}
fn persist_author(name: &str) {
#[cfg(target_arch = "wasm32")]
{
if let Some(win) = web_sys::window() {
if let Ok(Some(storage)) = win.local_storage() {
let _ = storage.set_item("mantra.author", name);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
{ let _ = name; }
}
// --- Textarea helpers -------------------------------------------------
fn clear_textarea(node: &NodeRef<html::Textarea>) {
#[cfg(target_arch = "wasm32")]
{
if let Some(el) = node.get_untracked() {
el.set_value("");
}
}
#[cfg(not(target_arch = "wasm32"))]
{ let _ = node; }
}
// --- Browser helpers -------------------------------------------------
fn clear_browser_selection() {
#[cfg(target_arch = "wasm32")]
{
if let Some(win) = web_sys::window() {
if let Ok(Some(sel)) = win.get_selection() {
let _ = sel.remove_all_ranges();
}
}
}
}
fn copy_to_clipboard(text: &str) {
#[cfg(target_arch = "wasm32")]
{
if let Some(win) = web_sys::window() {
let _ = win.navigator().clipboard().write_text(text);
}
}
#[cfg(not(target_arch = "wasm32"))]
{ let _ = text; }
}
// --- Debug log --------------------------------------------------------
fn log(msg: &str) {
#[cfg(target_arch = "wasm32")]
{
web_sys::console::log_1(&format!("[mantra] {msg}").into());
}
#[cfg(not(target_arch = "wasm32"))]
{ let _ = msg; }
}

View file

@ -115,6 +115,7 @@ fn CycleSection(
works_label: &'static str, works_label: &'static str,
themes_label: &'static str, themes_label: &'static str,
) -> impl IntoView { ) -> impl IntoView {
let cycle_slug = cycle.slug.clone();
let cycle_title = cycle.title.clone(); let cycle_title = cycle.title.clone();
let cycle_subtitle = cycle.subtitle.clone(); let cycle_subtitle = cycle.subtitle.clone();
let sources = cycle.sources.clone(); let sources = cycle.sources.clone();
@ -132,11 +133,37 @@ fn CycleSection(
Lang::En => format!(" · {n} sources"), Lang::En => format!(" · {n} sources"),
}; };
let (cycle_number, defining_question) = match (cycle_slug.as_str(), lang) {
("cycle-1-philosophy", Lang::Ru) => (
"01",
"почему форма трогает человеческое сердце?",
),
("cycle-1-philosophy", Lang::En) => (
"01",
"why does form move the human heart?",
),
("cycle-2-design-theory", Lang::Ru) => (
"02",
"как это воплощалось в методе мастеров?",
),
("cycle-2-design-theory", Lang::En) => (
"02",
"how was it practiced by the masters?",
),
_ => ("", ""),
};
view! { view! {
<section class="landing-cycle"> <section class="landing-cycle">
<header class="landing-cycle-head"> <header class="landing-cycle-head">
<div class="landing-cycle-number">{cycle_number}</div>
<div class="landing-cycle-headings">
<h2 class="landing-cycle-title">{cycle_title}</h2> <h2 class="landing-cycle-title">{cycle_title}</h2>
<p class="landing-cycle-subtitle">{cycle_subtitle}</p> <p class="landing-cycle-subtitle">{cycle_subtitle}</p>
{(!defining_question.is_empty()).then(|| view! {
<p class="landing-cycle-question">{defining_question}</p>
})}
</div>
</header> </header>
<div class="landing-grid"> <div class="landing-grid">
<div class="landing-col"> <div class="landing-col">

View file

@ -1,355 +0,0 @@
//! Margin layer — the drawer that opens when a paragraph is clicked.
//!
//! Holds: a Notes tab (write a thought, permanent record in vault) and
//! an Ask tab (ask Claude about the passage; Q+A is also saved to the
//! vault — the dialogue IS the record).
//!
//! Author identity for v0.1: an inline input pinned at the bottom of
//! the drawer, prefilled from `localStorage` under `mantra.author`.
//! No modal prompts.
use leptos::html;
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::api::{ask_claude, fetch_notes, save_note, NoteEntry, NoteKind};
use crate::corpus::Lang;
/// A paragraph the user opened the drawer on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActivePara {
pub id: String,
pub excerpt: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Tab {
Note,
Ask,
}
#[component]
pub fn MarginDrawer(
slug: Signal<String>,
lang: Signal<Lang>,
active: ReadSignal<Option<ActivePara>>,
set_active: WriteSignal<Option<ActivePara>>,
/// Bumped whenever the set of notes for this slug changes, so the
/// source page can re-annotate paragraph dots.
notes_tick: RwSignal<u32>,
) -> impl IntoView {
let (notes, set_notes) = signal::<Vec<NoteEntry>>(Vec::new());
let (tab, set_tab) = signal(Tab::Note);
let (draft, set_draft) = signal(String::new());
let (pending, set_pending) = signal(false);
let (error, set_error) = signal::<Option<String>>(None);
let (author, set_author) = signal(initial_author());
let textarea_ref: NodeRef<html::Textarea> = NodeRef::new();
// Refetch all notes for the current slug. Happens on: mount,
// slug change, after save, after ask.
let reload = move |slug_s: String| {
spawn_local(async move {
match fetch_notes(slug_s).await {
Ok(v) => {
set_notes.set(v);
notes_tick.update(|n| *n = n.wrapping_add(1));
}
Err(e) => {
log(&format!("fetch_notes error: {e}"));
}
}
});
};
// Track slug changes — reload notes.
Effect::new(move |_| {
let s = slug.get();
if !s.is_empty() {
reload(s);
}
});
// Clear draft/error/tab when switching paragraphs.
Effect::new(move |_| {
let _ = active.get();
set_draft.set(String::new());
set_error.set(None);
set_tab.set(Tab::Note);
clear_textarea(&textarea_ref);
});
let filtered = Memo::new(move |_| {
let Some(a) = active.get() else { return Vec::new() };
notes.with(|all| {
all.iter().filter(|e| e.para_id == a.id).cloned().collect::<Vec<_>>()
})
});
let close = move |_| set_active.set(None);
let submit = move || {
log("submit clicked");
let Some(a) = active.get_untracked() else {
log("submit: no active paragraph");
return;
};
let text = draft.get_untracked().trim().to_string();
if text.is_empty() {
log("submit: empty draft");
return;
}
let author_name = {
let a = author.get_untracked();
if a.trim().is_empty() { "anon".to_string() } else { a }
};
let slug_s = slug.get_untracked();
let para_id = a.id.clone();
let excerpt = a.excerpt.clone();
let current_tab = tab.get_untracked();
log(&format!(
"submit: tab={:?} slug={} para_id={} text_len={} author={}",
current_tab, slug_s, para_id, text.len(), author_name,
));
set_pending.set(true);
set_error.set(None);
spawn_local(async move {
let res = match current_tab {
Tab::Note => save_note(
slug_s.clone(),
para_id,
excerpt,
text,
author_name,
).await.map(|_| ()),
Tab::Ask => ask_claude(
slug_s.clone(),
para_id,
excerpt,
text,
author_name,
).await.map(|_| ()),
};
set_pending.set(false);
match res {
Ok(()) => {
log("submit: ok");
set_draft.set(String::new());
clear_textarea(&textarea_ref);
reload(slug_s);
}
Err(e) => {
log(&format!("submit: error {e}"));
set_error.set(Some(format!("{e}")));
}
}
});
};
let label_close = move || match lang.get() { Lang::Ru => "закрыть", Lang::En => "close" };
let label_note = move || match lang.get() { Lang::Ru => "заметка", Lang::En => "note" };
let label_ask = move || match lang.get() { Lang::Ru => "спросить", Lang::En => "ask" };
let placeholder_note = move || match lang.get() {
Lang::Ru => "запишите мысль…",
Lang::En => "write a thought…",
};
let placeholder_ask = move || match lang.get() {
Lang::Ru => "спросите Клода о пассаже…",
Lang::En => "ask Claude about the passage…",
};
let label_save = move || match lang.get() { Lang::Ru => "сохранить", Lang::En => "save" };
let label_ask_go = move || match lang.get() { Lang::Ru => "спросить", Lang::En => "ask" };
let label_pending_note = move || match lang.get() {
Lang::Ru => "сохраняю…",
Lang::En => "saving…",
};
let label_pending_ask = move || match lang.get() {
Lang::Ru => "думаю…",
Lang::En => "thinking…",
};
let label_you = move || match lang.get() { Lang::Ru => "вы:", Lang::En => "you:" };
view! {
<Show
when=move || active.get().is_some()
fallback=|| ().into_any()
>
<aside class="margin-drawer">
<header class="margin-drawer-head">
<div class="margin-drawer-tabs">
<button
class="tab"
class:tab-active=move || tab.get() == Tab::Note
on:click=move |_| set_tab.set(Tab::Note)
>{label_note}</button>
<button
class="tab"
class:tab-active=move || tab.get() == Tab::Ask
on:click=move |_| set_tab.set(Tab::Ask)
>{label_ask}</button>
</div>
<button class="margin-drawer-close" on:click=close aria-label=label_close>"×"</button>
</header>
<div class="margin-drawer-excerpt">
{move || active.get().map(|a| a.excerpt)}
</div>
<div class="margin-drawer-form">
<textarea
class="margin-drawer-input"
node_ref=textarea_ref
placeholder=move || match tab.get() {
Tab::Note => placeholder_note(),
Tab::Ask => placeholder_ask(),
}
on:input=move |ev| set_draft.set(event_target_value(&ev))
rows="4"
/>
{move || error.get().map(|e| view! {
<p class="margin-drawer-error">{e}</p>
})}
<div class="margin-drawer-actions">
<label class="margin-drawer-author">
<span class="margin-drawer-author-label">{label_you}</span>
<input
type="text"
class="margin-drawer-author-input"
prop:value=move || author.get()
on:input=move |ev| {
let v = event_target_value(&ev);
persist_author(&v);
set_author.set(v);
}
placeholder="anon"
/>
</label>
<button
type="button"
class="margin-drawer-submit"
disabled=move || pending.get() || draft.get().trim().is_empty()
on:click=move |_| submit()
>
{move || {
if pending.get() {
match tab.get() {
Tab::Note => label_pending_note(),
Tab::Ask => label_pending_ask(),
}
} else {
match tab.get() {
Tab::Note => label_save(),
Tab::Ask => label_ask_go(),
}
}
}}
</button>
</div>
</div>
<div class="margin-drawer-entries">
{move || {
let list = filtered.get();
list.into_iter().map(|e| view! {
<NoteCard entry=e/>
}).collect_view()
}}
</div>
</aside>
</Show>
}
}
#[component]
fn NoteCard(entry: NoteEntry) -> impl IntoView {
let is_ask = entry.kind == NoteKind::Ask;
let cls = if is_ask { "note-card note-card-ask" } else { "note-card" };
// For Ask entries the body is `**Q:** ...\n\n**A (Claude):** ...`;
// split so we can style them distinctly.
let (q, a) = if is_ask {
split_ask_body(&entry.body)
} else {
(None, Some(entry.body.clone()))
};
view! {
<article class=cls>
<header class="note-card-meta">{entry.author_ts}</header>
{q.map(|s| view! { <p class="note-q">{s}</p> })}
{a.map(|s| view! { <p class="note-a">{s}</p> })}
</article>
}
}
fn split_ask_body(body: &str) -> (Option<String>, Option<String>) {
let mut q = None;
let mut a = None;
if let Some(q_rest) = body.strip_prefix("**Q:**") {
if let Some((q_part, a_part)) = q_rest.split_once("**A (Claude):**") {
q = Some(q_part.trim().to_string());
a = Some(a_part.trim().to_string());
} else {
q = Some(q_rest.trim().to_string());
}
} else {
a = Some(body.trim().to_string());
}
(q, a)
}
// --- Author (inline, no modal prompt) ---------------------------------
fn initial_author() -> String {
#[cfg(target_arch = "wasm32")]
{
if let Some(win) = web_sys::window() {
if let Ok(Some(storage)) = win.local_storage() {
if let Ok(Some(name)) = storage.get_item("mantra.author") {
if !name.trim().is_empty() {
return name;
}
}
}
}
}
String::new()
}
fn persist_author(name: &str) {
#[cfg(target_arch = "wasm32")]
{
if let Some(win) = web_sys::window() {
if let Ok(Some(storage)) = win.local_storage() {
let _ = storage.set_item("mantra.author", name);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
{ let _ = name; }
}
// --- Textarea helpers -------------------------------------------------
fn clear_textarea(node: &NodeRef<html::Textarea>) {
#[cfg(target_arch = "wasm32")]
{
if let Some(el) = node.get_untracked() {
el.set_value("");
}
}
#[cfg(not(target_arch = "wasm32"))]
{ let _ = node; }
}
// --- Debug log --------------------------------------------------------
fn log(msg: &str) {
#[cfg(target_arch = "wasm32")]
{
web_sys::console::log_1(&format!("[mantra] {msg}").into());
}
#[cfg(not(target_arch = "wasm32"))]
{ let _ = msg; }
}

View file

@ -1,6 +1,6 @@
pub mod artifact; pub mod artifact;
pub mod inspector;
pub mod landing; pub mod landing;
pub mod margin;
pub mod shared; pub mod shared;
pub mod source; pub mod source;
pub mod theme; pub mod theme;

View file

@ -1,9 +1,10 @@
//! `/source/:slug` — one distillation, book-page typography. //! `/source/:slug` — one distillation, book-page typography.
//! //!
//! Reads its data via the `source_page` server fn wrapped in a //! v0.3: selection-based interaction. `mouseup` on the article reads
//! `Resource` so SSR and hydrate render from the same payload. //! `window.getSelection()` and opens a floating popover above the
//! This avoids needing the corpus `Arc<Corpus>` context on the //! selected range. The popover routes "Ask / Note / Copy" actions to
//! client (where it isn't available). //! the persistent right-side inspector. Paragraph-level click remains
//! as a fallback when the user just clicks (no selection).
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::hooks::{use_params, use_query_map}; use leptos_router::hooks::{use_params, use_query_map};
@ -11,7 +12,7 @@ use leptos_router::params::Params;
use crate::api::{fetch_source_page, SourcePageData}; use crate::api::{fetch_source_page, SourcePageData};
use crate::corpus::Lang; use crate::corpus::Lang;
use crate::pages::margin::{ActivePara, MarginDrawer}; use crate::pages::inspector::{Anchor, Inspector, PopoverPos, SelectionPopover, Tab};
use crate::pages::shared::LangToggle; use crate::pages::shared::LangToggle;
#[derive(Params, PartialEq, Clone, Debug)] #[derive(Params, PartialEq, Clone, Debug)]
@ -31,20 +32,26 @@ pub fn SourcePage() -> impl IntoView {
Lang::from_query(query.read().get("lang").as_deref()) Lang::from_query(query.read().get("lang").as_deref())
}); });
let (active, set_active) = signal::<Option<ActivePara>>(None); 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 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());
let lang_sig: Signal<Lang> = Signal::derive(move || lang.get()); let lang_sig: Signal<Lang> = Signal::derive(move || lang.get());
// Resource key = (slug, lang). Re-fetches when either changes. // Clear anchor + popover on slug change (new page).
Effect::new(move |_| {
let _ = slug.get();
set_anchor.set(None);
set_popover_pos.set(None);
});
let data = Resource::new( let data = Resource::new(
move || (slug.get(), lang.get().as_str().to_string()), move || (slug.get(), lang.get().as_str().to_string()),
|(s, l)| fetch_source_page(s, l), |(s, l)| fetch_source_page(s, l),
); );
// After hydration + notes load: annotate paragraphs that have
// marginalia so a dot appears in the left gutter.
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
{ {
let slug_for_fx = slug_sig; let slug_for_fx = slug_sig;
@ -64,6 +71,7 @@ pub fn SourcePage() -> impl IntoView {
view! { view! {
<LangToggle current=lang/> <LangToggle current=lang/>
<div class="reader-frame">
<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| {
@ -87,22 +95,60 @@ pub fn SourcePage() -> impl IntoView {
let SourcePageData { source: src, cycle, prev, next } = d; let SourcePageData { source: src, cycle, prev, next } = d;
let prev_href = prev.map(|p| format!("/source/{p}?lang={}", lang_val.as_str())); let prev_href = prev.map(|p| format!("/source/{p}?lang={}", lang_val.as_str()));
let next_href = next.map(|n| format!("/source/{n}?lang={}", lang_val.as_str())); let next_href = next.map(|n| format!("/source/{n}?lang={}", lang_val.as_str()));
let conf_label = match (lang_val, src.confidence.as_str()) {
(Lang::Ru, "high") => "высокая уверенность",
(Lang::Ru, "medium") => "средняя уверенность",
(Lang::Ru, "low") => "низкая уверенность",
(Lang::En, "high") => "high confidence",
(Lang::En, "medium") => "medium confidence",
(Lang::En, "low") => "low confidence",
_ => "",
};
let tags_for_hero = src.tags.clone();
view! { view! {
<main class="source"> <main class="source">
<nav class="source-breadcrumb"> <nav class="source-breadcrumb">
<a href=home_href.clone()>"design dna"</a> <a href=home_href.clone()>"design dna"</a>
<span class="sep">" · "</span> <span class="sep">" · "</span>
<span>{cycle.subtitle}</span> <span>{cycle.subtitle.clone()}</span>
<span class="sep">" · "</span> <span class="sep">" · "</span>
<span class="source-author">{src.author}</span> <span class="source-author">{src.author.clone()}</span>
</nav> </nav>
<header class="source-hero">
<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>
})}
<div class="source-hero-meta">
{(!conf_label.is_empty()).then(|| view! {
<span class="source-hero-confidence" data-level=src.confidence.clone()>
{conf_label}
</span>
})}
{(!tags_for_hero.is_empty()).then(|| view! {
<span class="source-hero-sep">" · "</span>
<span class="source-hero-tags">
{tags_for_hero.iter().take(4).cloned().collect::<Vec<_>>().join(" · ")}
</span>
})}
</div>
<div class="source-hero-rule"/>
</header>
<article <article
class="source-body" class="source-body"
inner_html=src.body_html inner_html=src.body_html
on:mouseup=move |ev| {
handle_mouseup(&ev, &set_popover_pos);
}
on:click=move |ev| { on:click=move |ev| {
if let Some(p) = paragraph_from_event(&ev) { if popover_pos.get_untracked().is_some() {
set_active.set(Some(p)); return;
}
if let Some(a) = paragraph_from_event(&ev) {
set_anchor.set(Some(a));
} }
} }
></article> ></article>
@ -135,20 +181,32 @@ pub fn SourcePage() -> impl IntoView {
}} }}
</Suspense> </Suspense>
<MarginDrawer <Inspector
slug=slug_sig slug=slug_sig
lang=lang_sig lang=lang_sig
active=active anchor=anchor
set_active=set_active set_anchor=set_anchor
tab=tab
set_tab=set_tab
notes_tick=notes_tick notes_tick=notes_tick
/> />
</div>
<SelectionPopover
pos=popover_pos
set_pos=set_popover_pos
set_anchor=set_anchor
set_tab=set_tab
lang=lang_sig
/>
} }
} }
/// 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 to seed the drawer. /// element; return its id + plain-text content (used as fallback anchor
/// when no selection is active).
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<ActivePara> { fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<Anchor> {
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
let target = ev.target()?; let target = ev.target()?;
let mut el = target.dyn_into::<web_sys::Element>().ok()?; let mut el = target.dyn_into::<web_sys::Element>().ok()?;
@ -157,14 +215,92 @@ fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<ActivePara> {
let id = el.get_attribute("data-para-id")?; let id = el.get_attribute("data-para-id")?;
let text = el.text_content().unwrap_or_default(); let text = el.text_content().unwrap_or_default();
let excerpt = text.trim().to_string(); let excerpt = text.trim().to_string();
return Some(ActivePara { id, excerpt }); return Some(Anchor {
para_id: id,
excerpt,
is_selection: false,
});
} }
el = el.parent_element()?; el = el.parent_element()?;
} }
} }
#[cfg(not(feature = "hydrate"))] #[cfg(not(feature = "hydrate"))]
fn paragraph_from_event(_ev: &leptos::ev::MouseEvent) -> Option<ActivePara> { fn paragraph_from_event(_ev: &leptos::ev::MouseEvent) -> Option<Anchor> {
None
}
/// Read `window.getSelection()` on mouseup; if there's a non-empty
/// selection inside a `[data-para-id]` paragraph, show the floating
/// popover above it.
#[cfg(feature = "hydrate")]
fn handle_mouseup(
_ev: &leptos::ev::MouseEvent,
set_pos: &WriteSignal<Option<PopoverPos>>,
) {
let Some(win) = web_sys::window() else { return };
let Ok(Some(sel)) = win.get_selection() else {
set_pos.set(None);
return;
};
if sel.is_collapsed() {
set_pos.set(None);
return;
}
let text = match sel.to_string().as_string() {
Some(s) => s.trim().to_string(),
None => {
set_pos.set(None);
return;
}
};
if text.len() < 2 {
set_pos.set(None);
return;
}
let Ok(range) = sel.get_range_at(0) else {
set_pos.set(None);
return;
};
let rect = range.get_bounding_client_rect();
let start_node = range.start_container().ok();
let para_id = start_node.as_ref().and_then(find_para_id_from_node);
let Some(para_id) = para_id else {
set_pos.set(None);
return;
};
set_pos.set(Some(PopoverPos {
x: rect.left() + rect.width() / 2.0,
y: rect.top() - 8.0,
anchor: Anchor {
para_id,
excerpt: text,
is_selection: true,
},
}));
}
#[cfg(not(feature = "hydrate"))]
fn handle_mouseup(
_ev: &leptos::ev::MouseEvent,
_set_pos: &WriteSignal<Option<PopoverPos>>,
) {}
/// Walk up from an arbitrary DOM node to find the ancestor with
/// `[data-para-id]`.
#[cfg(feature = "hydrate")]
fn find_para_id_from_node(node: &web_sys::Node) -> Option<String> {
use wasm_bindgen::JsCast;
let mut current: Option<web_sys::Element> = node
.dyn_ref::<web_sys::Element>()
.cloned()
.or_else(|| node.parent_element());
while let Some(el) = current {
if el.has_attribute("data-para-id") {
return el.get_attribute("data-para-id");
}
current = el.parent_element();
}
None None
} }

View file

@ -114,15 +114,15 @@ main {
max-width: 74ch; max-width: 74ch;
} }
.landing-head { margin-bottom: 4rem; } .landing-head { margin-bottom: 5rem; }
.landing-title { .landing-title {
font-family: var(--mantra-serif); font-family: var(--mantra-serif);
font-variation-settings: "opsz" 144, "SOFT" 50, "WONK" 0, "wght" 350; font-variation-settings: "opsz" 144, "SOFT" 50, "WONK" 0, "wght" 320;
font-size: clamp(3rem, 7vw, 4.5rem); font-size: clamp(3.5rem, 8.5vw, 5.5rem);
font-weight: 350; font-weight: 320;
line-height: 1.02; line-height: 0.98;
letter-spacing: -0.02em; letter-spacing: -0.025em;
margin: 0; margin: 0;
color: var(--mantra-fg); color: var(--mantra-fg);
} }
@ -134,18 +134,20 @@ main {
color: var(--mantra-muted); color: var(--mantra-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.22em; letter-spacing: 0.22em;
margin: 1rem 0 3rem; margin: 1.5rem 0 3.5rem;
} }
.landing-question { .landing-question {
font-family: var(--mantra-serif); font-family: var(--mantra-serif);
font-style: italic; font-style: italic;
font-variation-settings: "opsz" 36, "SOFT" 50, "WONK" 0, "wght" 380; font-variation-settings: "opsz" 36, "SOFT" 50, "WONK" 0, "wght" 380;
font-size: 1.4rem; font-size: clamp(1.25rem, 2.4vw, 1.5rem);
line-height: 1.4; line-height: 1.4;
color: var(--mantra-fg); color: var(--mantra-fg);
margin: 0 0 4rem; margin: 0 0 4.5rem;
max-width: 34ch; max-width: 32ch;
padding-left: 1.4rem;
border-left: 2px solid var(--mantra-accent);
} }
.landing-section-label { .landing-section-label {
@ -159,55 +161,88 @@ main {
} }
.landing-artifacts { .landing-artifacts {
margin-bottom: 5rem; margin-bottom: 6rem;
} }
.artifacts-list { .artifacts-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; display: grid;
flex-direction: column; grid-template-columns: 1fr 1fr;
gap: 0.9rem; gap: 1.25rem;
border-top: 1px solid var(--mantra-hairline); }
border-bottom: 1px solid var(--mantra-hairline); @media (max-width: 760px) {
padding: 1rem 0; .artifacts-list { grid-template-columns: 1fr; }
} }
.artifact-line a { .artifact-line a {
display: block; display: block;
border-bottom: none; border-bottom: none;
line-height: 1.4; line-height: 1.45;
padding: 1.25rem 1.4rem;
border: 1px solid var(--mantra-hairline);
border-radius: 6px;
background: color-mix(in srgb, var(--mantra-bg) 60%, transparent);
transition: border-color 0.18s, transform 0.18s, background 0.18s;
}
.artifact-line a:hover {
border-color: var(--mantra-accent);
transform: translateY(-1px);
} }
.artifact-title { .artifact-title {
display: block;
font-family: var(--mantra-serif); font-family: var(--mantra-serif);
font-variation-settings: "opsz" 22, "SOFT" 40, "WONK" 0, "wght" 520; font-variation-settings: "opsz" 36, "SOFT" 50, "WONK" 0, "wght" 480;
font-size: 1.05rem; font-size: 1.25rem;
color: var(--mantra-fg); color: var(--mantra-fg);
letter-spacing: -0.005em;
} }
.artifact-sep { color: var(--mantra-faint); font-size: 0.85rem; } .artifact-sep { display: none; }
.artifact-subtitle { .artifact-subtitle {
display: block;
margin-top: 0.4rem;
font-family: var(--mantra-serif);
font-style: italic; font-style: italic;
color: var(--mantra-muted); color: var(--mantra-muted);
font-size: 0.88rem; font-size: 0.9rem;
line-height: 1.4;
} }
.artifact-line a:hover .artifact-title { color: var(--mantra-accent); } .artifact-line a:hover .artifact-title { color: var(--mantra-accent); }
.landing-cycle { .landing-cycle {
margin-bottom: 4.5rem; margin-bottom: 5rem;
padding-top: 2rem; padding-top: 2.5rem;
border-top: 1px solid var(--mantra-hairline); border-top: 1px solid var(--mantra-hairline);
&:first-of-type { border-top: none; padding-top: 0; } &:first-of-type { border-top: none; padding-top: 0; }
} }
.landing-cycle-head { .landing-cycle-head {
margin-bottom: 2.5rem; display: grid;
grid-template-columns: auto 1fr;
gap: 1.8rem;
align-items: start;
margin-bottom: 3rem;
}
.landing-cycle-number {
font-family: var(--mantra-serif);
font-variation-settings: "opsz" 144, "SOFT" 50, "WONK" 0, "wght" 250;
font-size: clamp(3.5rem, 6vw, 5rem);
line-height: 0.9;
letter-spacing: -0.04em;
color: var(--mantra-faint);
font-feature-settings: "onum" 1, "tnum" 0;
}
.landing-cycle-headings {
padding-top: 0.4rem;
} }
.landing-cycle-title { .landing-cycle-title {
font-family: var(--mantra-serif); font-family: var(--mantra-serif);
font-variation-settings: "opsz" 60, "SOFT" 50, "WONK" 0, "wght" 400; font-variation-settings: "opsz" 72, "SOFT" 50, "WONK" 0, "wght" 400;
font-size: clamp(1.6rem, 3vw, 2.1rem); font-size: clamp(1.6rem, 3vw, 2.1rem);
line-height: 1.1; line-height: 1.1;
letter-spacing: -0.01em; letter-spacing: -0.01em;
@ -222,7 +257,25 @@ main {
color: var(--mantra-muted); color: var(--mantra-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.2em; letter-spacing: 0.2em;
margin: 0.4rem 0 0; margin: 0.5rem 0 0;
}
.landing-cycle-question {
font-family: var(--mantra-serif);
font-style: italic;
font-variation-settings: "opsz" 32, "SOFT" 50, "WONK" 0, "wght" 400;
font-size: clamp(1rem, 1.8vw, 1.15rem);
line-height: 1.45;
color: var(--mantra-muted);
margin: 1rem 0 0;
max-width: 42ch;
}
@media (max-width: 560px) {
.landing-cycle-head {
grid-template-columns: 1fr;
gap: 0.8rem;
}
} }
.landing-grid { .landing-grid {
@ -311,10 +364,95 @@ main {
a:hover { color: var(--mantra-fg); } a:hover { color: var(--mantra-fg); }
} }
// --- Source hero (title page, shown before body) --------------------
.source-hero {
margin: 4.5rem 0 5rem;
max-width: var(--mantra-measure);
}
.source-hero-eyebrow {
font-family: var(--mantra-sans);
font-variation-settings: "wght" 500;
font-size: 0.72rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--mantra-muted);
margin-bottom: 1.5rem;
}
.source-hero-title {
font-family: var(--mantra-serif);
font-variation-settings: "opsz" 144, "SOFT" 50, "WONK" 0, "wght" 360;
font-size: clamp(2.3rem, 5.5vw, 3.6rem);
line-height: 1.03;
letter-spacing: -0.018em;
margin: 0 0 2.2rem;
color: var(--mantra-fg);
}
.source-hero-claim {
font-family: var(--mantra-serif);
font-style: italic;
font-variation-settings: "opsz" 36, "SOFT" 50, "WONK" 0, "wght" 400;
font-size: clamp(1.15rem, 2.2vw, 1.35rem);
line-height: 1.5;
color: var(--mantra-fg);
margin: 0 0 2rem;
padding-left: 1.4rem;
border-left: 2px solid var(--mantra-accent);
max-width: 54ch;
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}
.source-hero-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
font-family: var(--mantra-sans);
font-size: 0.78rem;
color: var(--mantra-muted);
letter-spacing: 0.02em;
}
.source-hero-confidence {
font-variation-settings: "wght" 500;
&[data-level="high"] { color: var(--mantra-accent); }
&[data-level="medium"] { color: var(--mantra-muted); }
&[data-level="low"] { color: var(--mantra-faint); }
}
.source-hero-sep { color: var(--mantra-faint); }
.source-hero-tags {
font-family: var(--mantra-mono);
font-size: 0.72rem;
color: var(--mantra-faint);
letter-spacing: 0.04em;
}
.source-hero-rule {
height: 1px;
background: var(--mantra-hairline);
width: 6rem;
margin-top: 2.5rem;
}
// --- Source body ----------------------------------------------------
.source-body { .source-body {
// Positioning anchor for H2 margin-labels below. // Positioning anchor for H2 margin-labels below.
position: relative; position: relative;
// The first H1 is duplicated in the hero; hide here to avoid
// repeating the author/title at the top of the body.
> h1:first-child { display: none; }
// H1 is the work title // H1 is the work title
h1 { h1 {
font-family: var(--mantra-serif); font-family: var(--mantra-serif);
@ -572,40 +710,60 @@ main {
border-bottom: none; border-bottom: none;
} }
// --- Margin drawer (M.3) -------------------------------------------- // --- Inspector (v0.3 persistent right panel) ------------------------
.margin-drawer { // Reader frame: article centered in remaining space, inspector fixed right.
// On wide screens we reserve the inspector's width via padding-right on
// the frame this keeps `main { margin: 0 auto }` genuinely centered
// in the visible reading area instead of being pushed sideways.
.reader-frame {
position: relative;
transition: padding-right 0.2s;
}
@media (min-width: 1180px) {
.reader-frame { padding-right: 400px; }
}
.inspector {
position: fixed; position: fixed;
top: 0; top: 0;
right: 0; right: 0;
max-height: 100vh; bottom: 0;
width: min(420px, 90vw); width: 380px;
background: var(--mantra-bg); background: var(--mantra-bg);
border-left: 1px solid var(--mantra-hairline); border-left: 1px solid var(--mantra-hairline);
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.04); z-index: 15;
z-index: 20;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-family: var(--mantra-sans); font-family: var(--mantra-sans);
font-size: 0.92rem; font-size: 0.92rem;
color: var(--mantra-fg); color: var(--mantra-fg);
animation: drawer-in 0.22s ease-out;
overflow: hidden; overflow: hidden;
} }
@keyframes drawer-in { @media (max-width: 1180px) {
from { transform: translateX(16px); opacity: 0; } .inspector {
to { transform: translateX(0); opacity: 1; } position: fixed;
top: auto;
right: 0;
left: 0;
bottom: 0;
width: 100vw;
max-height: 55vh;
border-left: none;
border-top: 1px solid var(--mantra-hairline);
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.06);
}
} }
.margin-drawer-head { .inspector-head {
position: relative;
padding: 0.9rem 1.1rem 0.7rem; padding: 0.9rem 1.1rem 0.7rem;
border-bottom: 1px solid var(--mantra-hairline); border-bottom: 1px solid var(--mantra-hairline);
text-align: center; text-align: center;
} }
.margin-drawer-tabs { .inspector-tabs {
display: inline-flex; display: inline-flex;
gap: 0.2rem; gap: 0.2rem;
@ -632,54 +790,252 @@ main {
} }
} }
.margin-drawer-close { .inspector-anchor {
position: absolute; padding: 0.7rem 1.1rem;
top: 0.55rem; border-bottom: 1px solid var(--mantra-hairline);
right: 0.9rem; min-height: 3.2rem;
}
.inspector-hint {
font-family: var(--mantra-serif);
font-style: italic;
font-size: 0.85rem;
color: var(--mantra-faint);
margin: 0;
line-height: 1.4;
}
.inspector-anchor-chip {
display: flex;
align-items: flex-start;
gap: 0.4rem;
position: relative;
}
.inspector-excerpt {
flex: 1;
font-family: var(--mantra-serif);
font-style: italic;
font-size: 0.86rem;
line-height: 1.5;
color: var(--mantra-muted);
max-height: 5.5em;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
padding-left: 0.6rem;
border-left: 2px solid var(--mantra-hairline);
&.is-selection {
border-left-color: var(--mantra-accent);
color: var(--mantra-fg);
}
}
.inspector-anchor-clear {
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
color: var(--mantra-faint);
font-family: var(--mantra-serif); font-family: var(--mantra-serif);
font-size: 1.5rem; font-size: 1.1rem;
line-height: 1; line-height: 1;
color: var(--mantra-muted); padding: 0 0.25rem;
padding: 0.2rem 0.45rem;
transition: color 0.15s; transition: color 0.15s;
&:hover { color: var(--mantra-fg); } &:hover { color: var(--mantra-fg); }
} }
.margin-drawer-excerpt { .inspector-form {
padding: 0.8rem 1.1rem; padding: 0.9rem 1.1rem 1rem;
font-family: var(--mantra-serif); display: flex;
font-style: italic; flex-direction: column;
font-size: 0.88rem; gap: 0.6rem;
line-height: 1.5;
color: var(--mantra-muted);
border-bottom: 1px solid var(--mantra-hairline); border-bottom: 1px solid var(--mantra-hairline);
max-height: 6.5em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
} }
// Entries sit below the form (reading flows downward: write record). .inspector-input {
// They scroll within their own area when long; when empty, they add width: 100%;
// minimal height so the drawer collapses to just head+excerpt+form. resize: vertical;
.margin-drawer-entries { min-height: 4.5rem;
flex: 0 1 auto; background: var(--mantra-bg);
max-height: 42vh; color: var(--mantra-fg);
border: 1px solid var(--mantra-hairline);
border-radius: 4px;
padding: 0.55rem 0.7rem;
font-family: var(--mantra-serif);
font-size: 0.95rem;
line-height: 1.45;
transition: border-color 0.15s;
&:focus {
outline: none;
border-color: var(--mantra-accent);
}
}
.inspector-actions {
display: flex;
align-items: center;
gap: 0.8rem;
}
.inspector-author {
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex: 1;
min-width: 0;
.inspector-author-label {
font-size: 0.72rem;
color: var(--mantra-muted);
letter-spacing: 0.06em;
text-transform: lowercase;
font-variation-settings: "wght" 500;
}
.inspector-author-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
border-bottom: 1px dashed var(--mantra-faint);
font-family: var(--mantra-sans);
font-size: 0.82rem;
color: var(--mantra-fg);
padding: 0.1rem 0 0.15rem;
outline: none;
transition: border-color 0.15s;
&:focus { border-bottom-color: var(--mantra-accent); }
&::placeholder { color: var(--mantra-faint); }
}
}
.inspector-submit {
background: var(--mantra-accent);
color: var(--mantra-bg);
border: none;
border-radius: 14px;
padding: 0.45rem 1.1rem;
font-family: var(--mantra-sans);
font-size: 0.82rem;
font-variation-settings: "wght" 600;
letter-spacing: 0.06em;
text-transform: lowercase;
cursor: pointer;
transition: opacity 0.15s, transform 0.12s;
&:hover:not(:disabled) { transform: translateY(-1px); }
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.inspector-error {
color: #b45309;
font-family: var(--mantra-mono);
font-size: 0.78rem;
margin: 0;
word-break: break-word;
}
.inspector-entries {
flex: 1 1 auto;
overflow-y: auto; overflow-y: auto;
padding: 0.6rem 1.1rem 1rem; padding: 0.7rem 1.1rem 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.9rem; gap: 0.9rem;
border-top: 1px solid var(--mantra-hairline); }
.inspector-entries-label {
font-family: var(--mantra-sans);
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--mantra-muted);
margin-bottom: 0.2rem;
&:empty { display: none; } &:empty { display: none; }
} }
// --- Selection popover (floating over selected text) ----------------
.selpop {
position: fixed;
transform: translate(-50%, -100%);
z-index: 30;
display: flex;
align-items: center;
gap: 0;
background: var(--mantra-fg);
color: var(--mantra-bg);
border-radius: 18px;
padding: 0.2rem 0.3rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
font-family: var(--mantra-sans);
font-size: 0.78rem;
animation: selpop-in 0.12s ease-out;
white-space: nowrap;
}
@keyframes selpop-in {
from { opacity: 0; transform: translate(-50%, -95%); }
to { opacity: 1; transform: translate(-50%, -100%); }
}
.selpop::after {
content: "";
position: absolute;
left: 50%;
bottom: -5px;
transform: translateX(-50%) rotate(45deg);
width: 10px;
height: 10px;
background: var(--mantra-fg);
}
.selpop-btn {
background: transparent;
border: none;
color: var(--mantra-bg);
cursor: pointer;
padding: 0.35rem 0.7rem;
border-radius: 14px;
font-family: var(--mantra-sans);
font-size: 0.78rem;
font-variation-settings: "wght" 500;
letter-spacing: 0.04em;
display: inline-flex;
align-items: center;
gap: 0.3rem;
transition: background 0.12s;
&:hover { background: rgba(255, 255, 255, 0.1); }
}
.selpop-ask {
color: var(--mantra-bg);
font-variation-settings: "wght" 600;
}
.selpop-ico {
color: var(--mantra-accent);
font-size: 0.9rem;
}
.selpop-sep {
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.15);
margin: 0 0.15rem;
}
.selpop-close {
padding: 0.35rem 0.55rem;
font-size: 1rem;
line-height: 1;
}
.margin-empty { .margin-empty {
color: var(--mantra-muted); color: var(--mantra-muted);
font-style: italic; font-style: italic;