mantra v0.3: selection popover + persistent inspector + source hero + landing polish
This commit is contained in:
parent
d501b1b700
commit
d686692b1a
8 changed files with 1307 additions and 568 deletions
|
|
@ -26,6 +26,12 @@ web-sys = { version = "0.3", optional = true, features = [
|
|||
"DomTokenList",
|
||||
"Event",
|
||||
"EventTarget",
|
||||
"Selection",
|
||||
"Range",
|
||||
"Node",
|
||||
"DomRect",
|
||||
"Navigator",
|
||||
"Clipboard",
|
||||
"console",
|
||||
] }
|
||||
# Server-only — pulled in under ssr feature for notes I/O + Claude HTTP.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
//! `/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_router::hooks::{use_params, use_query_map};
|
||||
|
|
@ -11,7 +6,7 @@ use leptos_router::params::Params;
|
|||
|
||||
use crate::api::{fetch_artifact, ArtifactPageData};
|
||||
use crate::corpus::Lang;
|
||||
use crate::pages::margin::{ActivePara, MarginDrawer};
|
||||
use crate::pages::inspector::{Anchor, Inspector, PopoverPos, SelectionPopover, Tab};
|
||||
use crate::pages::shared::LangToggle;
|
||||
|
||||
#[derive(Params, PartialEq, Clone, Debug)]
|
||||
|
|
@ -31,12 +26,20 @@ pub fn ArtifactPage() -> impl IntoView {
|
|||
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 slug_sig: Signal<String> = Signal::derive(move || slug.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(
|
||||
move || (slug.get(), lang.get().as_str().to_string()),
|
||||
|(s, l)| fetch_artifact(s, l),
|
||||
|
|
@ -61,6 +64,7 @@ pub fn ArtifactPage() -> impl IntoView {
|
|||
|
||||
view! {
|
||||
<LangToggle current=lang/>
|
||||
<div class="reader-frame">
|
||||
<Suspense fallback=move || view! { <p class="source-loading">"…"</p> }>
|
||||
{move || {
|
||||
data.get().map(|res| {
|
||||
|
|
@ -83,9 +87,15 @@ pub fn ArtifactPage() -> impl IntoView {
|
|||
<article
|
||||
class="source-body"
|
||||
inner_html=artifact.body_html
|
||||
on:mouseup=move |ev| {
|
||||
handle_mouseup(&ev, &set_popover_pos);
|
||||
}
|
||||
on:click=move |ev| {
|
||||
if let Some(p) = paragraph_from_event(&ev) {
|
||||
set_active.set(Some(p));
|
||||
if popover_pos.get_untracked().is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(a) = paragraph_from_event(&ev) {
|
||||
set_anchor.set(Some(a));
|
||||
}
|
||||
}
|
||||
></article>
|
||||
|
|
@ -111,18 +121,29 @@ pub fn ArtifactPage() -> impl IntoView {
|
|||
}}
|
||||
</Suspense>
|
||||
|
||||
<MarginDrawer
|
||||
<Inspector
|
||||
slug=slug_sig
|
||||
lang=lang_sig
|
||||
active=active
|
||||
set_active=set_active
|
||||
anchor=anchor
|
||||
set_anchor=set_anchor
|
||||
tab=tab
|
||||
set_tab=set_tab
|
||||
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")]
|
||||
fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<ActivePara> {
|
||||
fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<Anchor> {
|
||||
use wasm_bindgen::JsCast;
|
||||
let target = ev.target()?;
|
||||
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 text = el.text_content().unwrap_or_default();
|
||||
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()?;
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
|
|
|
|||
485
crates/mantra-ui/src/pages/inspector.rs
Normal file
485
crates/mantra-ui/src/pages/inspector.rs
Normal 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; }
|
||||
}
|
||||
|
|
@ -115,6 +115,7 @@ fn CycleSection(
|
|||
works_label: &'static str,
|
||||
themes_label: &'static str,
|
||||
) -> impl IntoView {
|
||||
let cycle_slug = cycle.slug.clone();
|
||||
let cycle_title = cycle.title.clone();
|
||||
let cycle_subtitle = cycle.subtitle.clone();
|
||||
let sources = cycle.sources.clone();
|
||||
|
|
@ -132,11 +133,37 @@ fn CycleSection(
|
|||
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! {
|
||||
<section class="landing-cycle">
|
||||
<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>
|
||||
<p class="landing-cycle-subtitle">{cycle_subtitle}</p>
|
||||
{(!defining_question.is_empty()).then(|| view! {
|
||||
<p class="landing-cycle-question">{defining_question}</p>
|
||||
})}
|
||||
</div>
|
||||
</header>
|
||||
<div class="landing-grid">
|
||||
<div class="landing-col">
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
pub mod artifact;
|
||||
pub mod inspector;
|
||||
pub mod landing;
|
||||
pub mod margin;
|
||||
pub mod shared;
|
||||
pub mod source;
|
||||
pub mod theme;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
//! `/source/:slug` — one distillation, book-page typography.
|
||||
//!
|
||||
//! Reads its data via the `source_page` server fn wrapped in a
|
||||
//! `Resource` so SSR and hydrate render from the same payload.
|
||||
//! This avoids needing the corpus `Arc<Corpus>` context on the
|
||||
//! client (where it isn't available).
|
||||
//! v0.3: selection-based interaction. `mouseup` on the article reads
|
||||
//! `window.getSelection()` and opens a floating popover above the
|
||||
//! selected range. The popover routes "Ask / Note / Copy" actions to
|
||||
//! the persistent right-side inspector. Paragraph-level click remains
|
||||
//! as a fallback when the user just clicks (no selection).
|
||||
|
||||
use leptos::prelude::*;
|
||||
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::corpus::Lang;
|
||||
use crate::pages::margin::{ActivePara, MarginDrawer};
|
||||
use crate::pages::inspector::{Anchor, Inspector, PopoverPos, SelectionPopover, Tab};
|
||||
use crate::pages::shared::LangToggle;
|
||||
|
||||
#[derive(Params, PartialEq, Clone, Debug)]
|
||||
|
|
@ -31,20 +32,26 @@ pub fn SourcePage() -> impl IntoView {
|
|||
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 slug_sig: Signal<String> = Signal::derive(move || slug.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(
|
||||
move || (slug.get(), lang.get().as_str().to_string()),
|
||||
|(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")]
|
||||
{
|
||||
let slug_for_fx = slug_sig;
|
||||
|
|
@ -64,6 +71,7 @@ pub fn SourcePage() -> impl IntoView {
|
|||
|
||||
view! {
|
||||
<LangToggle current=lang/>
|
||||
<div class="reader-frame">
|
||||
<Suspense fallback=move || view! { <p class="source-loading">"…"</p> }>
|
||||
{move || {
|
||||
data.get().map(|res| {
|
||||
|
|
@ -87,22 +95,60 @@ pub fn SourcePage() -> impl IntoView {
|
|||
let SourcePageData { source: src, cycle, prev, next } = d;
|
||||
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 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! {
|
||||
<main class="source">
|
||||
<nav class="source-breadcrumb">
|
||||
<a href=home_href.clone()>"design dna"</a>
|
||||
<span class="sep">" · "</span>
|
||||
<span>{cycle.subtitle}</span>
|
||||
<span>{cycle.subtitle.clone()}</span>
|
||||
<span class="sep">" · "</span>
|
||||
<span class="source-author">{src.author}</span>
|
||||
<span class="source-author">{src.author.clone()}</span>
|
||||
</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
|
||||
class="source-body"
|
||||
inner_html=src.body_html
|
||||
on:mouseup=move |ev| {
|
||||
handle_mouseup(&ev, &set_popover_pos);
|
||||
}
|
||||
on:click=move |ev| {
|
||||
if let Some(p) = paragraph_from_event(&ev) {
|
||||
set_active.set(Some(p));
|
||||
if popover_pos.get_untracked().is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(a) = paragraph_from_event(&ev) {
|
||||
set_anchor.set(Some(a));
|
||||
}
|
||||
}
|
||||
></article>
|
||||
|
|
@ -135,20 +181,32 @@ pub fn SourcePage() -> impl IntoView {
|
|||
}}
|
||||
</Suspense>
|
||||
|
||||
<MarginDrawer
|
||||
<Inspector
|
||||
slug=slug_sig
|
||||
lang=lang_sig
|
||||
active=active
|
||||
set_active=set_active
|
||||
anchor=anchor
|
||||
set_anchor=set_anchor
|
||||
tab=tab
|
||||
set_tab=set_tab
|
||||
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]`
|
||||
/// 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")]
|
||||
fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<ActivePara> {
|
||||
fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<Anchor> {
|
||||
use wasm_bindgen::JsCast;
|
||||
let target = ev.target()?;
|
||||
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 text = el.text_content().unwrap_or_default();
|
||||
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()?;
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
|
|
|
|||
492
sass/main.scss
492
sass/main.scss
|
|
@ -114,15 +114,15 @@ main {
|
|||
max-width: 74ch;
|
||||
}
|
||||
|
||||
.landing-head { margin-bottom: 4rem; }
|
||||
.landing-head { margin-bottom: 5rem; }
|
||||
|
||||
.landing-title {
|
||||
font-family: var(--mantra-serif);
|
||||
font-variation-settings: "opsz" 144, "SOFT" 50, "WONK" 0, "wght" 350;
|
||||
font-size: clamp(3rem, 7vw, 4.5rem);
|
||||
font-weight: 350;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.02em;
|
||||
font-variation-settings: "opsz" 144, "SOFT" 50, "WONK" 0, "wght" 320;
|
||||
font-size: clamp(3.5rem, 8.5vw, 5.5rem);
|
||||
font-weight: 320;
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0;
|
||||
color: var(--mantra-fg);
|
||||
}
|
||||
|
|
@ -134,18 +134,20 @@ main {
|
|||
color: var(--mantra-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
margin: 1rem 0 3rem;
|
||||
margin: 1.5rem 0 3.5rem;
|
||||
}
|
||||
|
||||
.landing-question {
|
||||
font-family: var(--mantra-serif);
|
||||
font-style: italic;
|
||||
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;
|
||||
color: var(--mantra-fg);
|
||||
margin: 0 0 4rem;
|
||||
max-width: 34ch;
|
||||
margin: 0 0 4.5rem;
|
||||
max-width: 32ch;
|
||||
padding-left: 1.4rem;
|
||||
border-left: 2px solid var(--mantra-accent);
|
||||
}
|
||||
|
||||
.landing-section-label {
|
||||
|
|
@ -159,55 +161,88 @@ main {
|
|||
}
|
||||
|
||||
.landing-artifacts {
|
||||
margin-bottom: 5rem;
|
||||
margin-bottom: 6rem;
|
||||
}
|
||||
|
||||
.artifacts-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
border-top: 1px solid var(--mantra-hairline);
|
||||
border-bottom: 1px solid var(--mantra-hairline);
|
||||
padding: 1rem 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.artifacts-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.artifact-line a {
|
||||
display: block;
|
||||
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 {
|
||||
display: block;
|
||||
font-family: var(--mantra-serif);
|
||||
font-variation-settings: "opsz" 22, "SOFT" 40, "WONK" 0, "wght" 520;
|
||||
font-size: 1.05rem;
|
||||
font-variation-settings: "opsz" 36, "SOFT" 50, "WONK" 0, "wght" 480;
|
||||
font-size: 1.25rem;
|
||||
color: var(--mantra-fg);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.artifact-sep { color: var(--mantra-faint); font-size: 0.85rem; }
|
||||
.artifact-sep { display: none; }
|
||||
.artifact-subtitle {
|
||||
display: block;
|
||||
margin-top: 0.4rem;
|
||||
font-family: var(--mantra-serif);
|
||||
font-style: italic;
|
||||
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); }
|
||||
|
||||
.landing-cycle {
|
||||
margin-bottom: 4.5rem;
|
||||
padding-top: 2rem;
|
||||
margin-bottom: 5rem;
|
||||
padding-top: 2.5rem;
|
||||
border-top: 1px solid var(--mantra-hairline);
|
||||
|
||||
&:first-of-type { border-top: none; padding-top: 0; }
|
||||
}
|
||||
|
||||
.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 {
|
||||
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);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
|
|
@ -222,7 +257,25 @@ main {
|
|||
color: var(--mantra-muted);
|
||||
text-transform: uppercase;
|
||||
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 {
|
||||
|
|
@ -311,10 +364,95 @@ main {
|
|||
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 {
|
||||
// Positioning anchor for H2 margin-labels below.
|
||||
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 {
|
||||
font-family: var(--mantra-serif);
|
||||
|
|
@ -572,40 +710,60 @@ main {
|
|||
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;
|
||||
top: 0;
|
||||
right: 0;
|
||||
max-height: 100vh;
|
||||
width: min(420px, 90vw);
|
||||
bottom: 0;
|
||||
width: 380px;
|
||||
background: var(--mantra-bg);
|
||||
border-left: 1px solid var(--mantra-hairline);
|
||||
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.04);
|
||||
z-index: 20;
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--mantra-sans);
|
||||
font-size: 0.92rem;
|
||||
color: var(--mantra-fg);
|
||||
animation: drawer-in 0.22s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes drawer-in {
|
||||
from { transform: translateX(16px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
@media (max-width: 1180px) {
|
||||
.inspector {
|
||||
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 {
|
||||
position: relative;
|
||||
.inspector-head {
|
||||
padding: 0.9rem 1.1rem 0.7rem;
|
||||
border-bottom: 1px solid var(--mantra-hairline);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.margin-drawer-tabs {
|
||||
.inspector-tabs {
|
||||
display: inline-flex;
|
||||
gap: 0.2rem;
|
||||
|
||||
|
|
@ -632,54 +790,252 @@ main {
|
|||
}
|
||||
}
|
||||
|
||||
.margin-drawer-close {
|
||||
position: absolute;
|
||||
top: 0.55rem;
|
||||
right: 0.9rem;
|
||||
.inspector-anchor {
|
||||
padding: 0.7rem 1.1rem;
|
||||
border-bottom: 1px solid var(--mantra-hairline);
|
||||
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;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--mantra-faint);
|
||||
font-family: var(--mantra-serif);
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
color: var(--mantra-muted);
|
||||
padding: 0.2rem 0.45rem;
|
||||
padding: 0 0.25rem;
|
||||
transition: color 0.15s;
|
||||
&:hover { color: var(--mantra-fg); }
|
||||
}
|
||||
|
||||
.margin-drawer-excerpt {
|
||||
padding: 0.8rem 1.1rem;
|
||||
font-family: var(--mantra-serif);
|
||||
font-style: italic;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
color: var(--mantra-muted);
|
||||
.inspector-form {
|
||||
padding: 0.9rem 1.1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
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).
|
||||
// They scroll within their own area when long; when empty, they add
|
||||
// minimal height so the drawer collapses to just head+excerpt+form.
|
||||
.margin-drawer-entries {
|
||||
flex: 0 1 auto;
|
||||
max-height: 42vh;
|
||||
.inspector-input {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 4.5rem;
|
||||
background: var(--mantra-bg);
|
||||
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;
|
||||
padding: 0.6rem 1.1rem 1rem;
|
||||
padding: 0.7rem 1.1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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; }
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
color: var(--mantra-muted);
|
||||
font-style: italic;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue