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",
|
"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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
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">
|
||||||
|
|
|
||||||
|
|
@ -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 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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
492
sass/main.scss
492
sass/main.scss
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue