diff --git a/agents.md b/agents.md index 79b8633a72c0b6ca8f7361129fb471566179bf57..151afaaf7048fbbbec85fada3532cc7ddf6f45c5 100644 --- a/agents.md +++ b/agents.md @@ -66,6 +66,8 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **Aspect sub-scopes:** A `:slug` / `:slug {prompt}` / bare `:` statement is parse-derived from ingest text (no event-schema change, no new write path). Votes under an aspect go to a ranking group keyed `(scope_item, aspect)` where `scope_item` is a shared active-membership scope of both vote items; the canonical `ranking_group` and `rank_position_cache` stay aspect-free. Garden parent/item pages (`render_scope_view`, public `~/`) and the `/try` editor preview list aspect sections **below** the canonical ranking when that scope has aspect votes (heading `:{slug}`, optional prompt). No `?aspect=` routes or default-aspect redirects. CLI `garden children --aspect` / `garden rank --aspect` and MCP `get_rank.aspect` are opt-in reads via `GetGardenRank.aspect`. Prompts live on `ContentState.aspect_prompts` per `(room scope, slug)`. `parent_path` in those reads resolves to its leaf item (`~/x/luke` → `~luke`). +- **Garden page flow and edge anchors:** `~/` item pages read top → bottom as **parents (↑ `member of` card) → body → children (↓ ranked table)**. A blue **flow gradient** on the left edge descends parents (full blue) → hero (mid) → children (grey) — see the `GARDEN HIERARCHY` block in each of `theme_default.css`, `theme_retro_craft.css`, and `theme_retro.css`; garden hierarchy CSS (including the structural flex that keeps per-edge vote/§ links off parent titles) must exist in every theme that styles ontology pages. Every containment edge has a fragment URL: `#edge--in-` on the child's page (each parent row links its scope vote pool as the vote home, plus a `§` permalink), and `#item-` on ranked/unranked rows of the parent scope page (`aspect--` prefixed in aspect tables). Weight footnotes show totals only (`containment N · border M`); path sugar is not distinguished in the UI. + - **Browser auth redirects:** `/login`, `/join/:token`, `/auth/login`, and `/auth/choose-username` may carry **`next`** (or legacy **`redirect`**) as a **safe local path only**. The value is stored on the RAM-only pending session and applied after OAuth / username selection. **Rule of thumb:** New **CLI or API** verbs → `RpcCommand`. New **in-page morph or form-driven** behavior that only makes sense in the browser → `HtmlUiAction`. If both need the same operation, implement the real work once (e.g. call shared RPC helpers from `post_ui_html`) and keep the wire shapes separate. diff --git a/server/src/html/breadcrumb_path.rs b/server/src/html/breadcrumb_path.rs index b51cd7dc1f1f2791f5138bc3040e4e9bad602d0f..5c433481a6ae026d435927c3f005f76402ff5152 100644 --- a/server/src/html/breadcrumb_path.rs +++ b/server/src/html/breadcrumb_path.rs @@ -2,10 +2,12 @@ use crate::path_types::{tilde_http_path_to_item_id, ItemId}; use slug_types::SLUG_TILDE_ONTOLOGY_ROOT; /// Semantic view of an ontology path for rendering and routing decisions. +/// +/// Tilde items have leaf identity (`~/x/luke` is `~luke`), so the typed path beyond +/// the leaf carries no meaning: nested inputs 308 to the leaf before rendering, and +/// breadcrumbs come from the containment graph (strongest-parent walk), not URL segments. pub(super) struct OntologyPath { item: ItemId, - /// Breadcrumb segments: for `~/a/b` this is `["a", "b"]` (leading `~` rendered separately). - segments: Vec, } impl OntologyPath { @@ -17,15 +19,7 @@ impl OntologyPath { } pub(super) fn from_item(item: ItemId) -> Self { - // tilde_segments() returns ["~", "a", "b"] but bc_path() renders "~" itself, - // so we skip the leading "~" segment here. - let segments = item - .tilde_segments() - .into_iter() - .skip(1) // drop the leading "~" - .map(|s| s.to_string()) - .collect(); - Self { item, segments } + Self { item } } /// Nested `x/luke` → canonical leaf path `/~/luke`. `luke` (already a leaf) → `None`. @@ -53,7 +47,7 @@ impl OntologyPath { } pub(super) fn is_root(&self) -> bool { - self.segments.is_empty() + self.item.tilde_tail() == Some("") || self.item == ItemId::ontology_root() } /// At ontology root, allow mode toggle to home (`/`). @@ -66,10 +60,6 @@ impl OntologyPath { } } - pub(super) fn segments(&self) -> &[String] { - &self.segments - } - pub(super) fn as_str(&self) -> &str { self.item.as_str() } diff --git a/server/src/html/garden/item.rs b/server/src/html/garden/item.rs index ad46407e545a9a5ce7cd3c1f3dfe5f120c4457f2..febf095608836fd7e2a5411fce44b02bf820d83a 100644 --- a/server/src/html/garden/item.rs +++ b/server/src/html/garden/item.rs @@ -115,8 +115,8 @@ pub(super) fn garden_depth_select_markup(current: usize) -> Markup { } html! { form method="get" class="garden-depth-control" { - label { - span class="muted" { "depth" } + label title="How many scope levels down to include (follows membership, not URL paths)" { + span class="muted" { "scope depth" } " " select id="garden-depth-select" name="depth" aria-label="Garden ranking depth" onchange="this.form.submit()" { diff --git a/server/src/html/garden/item_page.rs b/server/src/html/garden/item_page.rs index 74fd96d967a50a6608a1b1bf43a1db34f5d778d6..cb3b26267159afba4c5734e7ca6fcdd7feb227d1 100644 --- a/server/src/html/garden/item_page.rs +++ b/server/src/html/garden/item_page.rs @@ -16,6 +16,7 @@ use crate::{ use super::{ access::content_for_garden_view, item::{item_display_path, item_href}, + vote::vote_pool_href, }; #[derive(Debug, Clone)] @@ -81,19 +82,26 @@ pub(super) struct AspectRankingView { pub(super) rankings: ChildrenRankings, } +/// Sibling nav for one scope: the item's fellow members under that scope. +#[derive(Debug, Clone)] +pub(super) struct ScopedSiblingNav { + pub(super) scope: ItemId, + pub(super) bar: SiblingNavBar, +} + #[derive(Debug, Clone)] pub(super) struct ItemPageViewModel { pub(super) item: String, pub(super) body: Option, - pub(super) sibling_nav: Option, - /// False at the tilde ontology root (`~/`): sibling-rank footnote does not apply. - pub(super) item_has_parent: bool, + /// One sibling nav per active scope (primary / strongest scope first). + /// Empty when the item has no scope with 2+ fellow members. + pub(super) sibling_navs: Vec, + /// Active scopes of this item, primary (strongest containment) first. Empty at root. + pub(super) scopes: Vec, /// True when this item has active members (it is used as a scope / role). pub(super) is_scope: bool, /// Strongest-parent walk, root-adjacent first, including the current item. Empty at root. pub(super) crumb_chain: Vec, - /// Active scopes other than the primary (strongest) parent. - pub(super) alternate_scopes: Vec, /// Active memberships (this item as child) with weights. pub(super) memberships: Vec, /// Suspended borders (`containment_weight <= border_weight`) where this item is the child. @@ -212,15 +220,32 @@ pub(super) fn aspect_rankings_for_parent( .collect() } -fn build_sibling_nav( +/// Sibling navs for every active scope of `current` (primary scope first). +/// A scope with fewer than 2 total members yields no nav (nothing to rank against). +fn build_sibling_navs( reduced: &crate::reducer::ReducerState, scope: &ScopeId, current: &ItemId, -) -> Option { + scopes: &[ItemId], +) -> Vec { let current = current.clone().normalized_storage().ontology_leaf(); let content = content_for_garden_view(reduced, scope); - let parent = strongest_parent(content, ¤t)?.normalized_storage(); - let rankings = build_children_rankings(content, &parent); + let mut out = Vec::new(); + for parent in scopes { + let parent = parent.clone().normalized_storage(); + if let Some(nav) = build_sibling_nav_in_scope(content, ¤t, &parent) { + out.push(ScopedSiblingNav { scope: parent, bar: nav }); + } + } + out +} + +fn build_sibling_nav_in_scope( + content: &ContentState, + current: &ItemId, + parent: &ItemId, +) -> Option { + let rankings = build_children_rankings(content, parent); let mut groups: Vec = Vec::new(); for comp in &rankings.component_rankings { let links: Vec = comp @@ -324,39 +349,107 @@ pub(super) fn sibling_nav_markup( } } -fn pair_weight_label(pair: &BorderPairState) -> String { +/// Edge weight footnote: total containment vs border. Path sugar counts the +/// same as explicit `<:` claims here — sugar is not special in the UI. +pub(super) fn pair_weight_label(pair: &BorderPairState) -> String { format!( "containment {} · border {}", pair.containment_weight, pair.border_weight ) } +/// Fragment-safe slug for `#edge-…` / `#item-…` anchors: lowercase alnum plus +/// `-`/`_`, everything else folds to a single `-`. Best-effort uniqueness — +/// tilde leaves are unique by construction; URL items can theoretically collide. +pub(super) fn fragment_slug(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for c in raw.chars() { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + out.push(c.to_ascii_lowercase()); + } else if !out.is_empty() && !out.ends_with('-') { + out.push('-'); + } + } + out.trim_matches('-').to_string() +} + +/// Stable fragment id for one containment edge, so every edge has its own URL +/// (`/~/hop#edge-hop-in-durable`). The matching child row on the parent page +/// carries `#item-` (see `ont_ranking_lists_markup`). The ontology root +/// has an empty leaf segment, so it falls back to `root`. +pub(super) fn edge_fragment_id(child: &ItemId, parent: &ItemId) -> String { + let child_slug = fragment_slug(child.last_segment()); + let parent_slug = fragment_slug(parent.last_segment()); + format!( + "edge-{}-in-{}", + if child_slug.is_empty() { + "root".to_string() + } else { + child_slug + }, + if parent_slug.is_empty() { + "root".to_string() + } else { + parent_slug + }, + ) +} + +/// UP card: everything about this item's upward edges, rendered ABOVE the item +/// body so the page flows parents → self → children. One row per active scope +/// (primary first), each merging the parent link, this item's position strip +/// among its fellows, the containment/border weight, and the vote home for +/// that scope — so every edge has its own `#edge-…` URL and every scope's +/// ranking contest has a vote link. pub(super) fn item_relations_markup( model: &ItemPageViewModel, nav: &ThreadNav, now: i64, ) -> maud::Markup { html! { - @if !model.alternate_scopes.is_empty() { - p class="muted ont-alt-scopes" { - "also in " - @for (i, scope) in model.alternate_scopes.iter().enumerate() { - @if i > 0 { span class="muted" { " · " } } - a href=(item_href(scope.as_str(), nav)) { (item_display_path(scope.as_str())) } - } - } - } @if !model.memberships.is_empty() { - section class="ont-tab-panel ont-tab-panel-memberships" { - h3 { "memberships" } - ul class="ont-group-list" { - @for pair in &model.memberships { - li { - a href=(item_href(pair.parent.as_str(), nav)) { - (item_display_path(pair.parent.as_str())) + section class="ont-tab-panel ont-tab-panel-memberships" id="parents" { + h3 { + span aria-hidden="true" { "↑ " } + "member of" + span class="muted" { (format!(" ({})", model.memberships.len())) } + } + @let item_id = ItemId::parse(&model.item) + .map(|i| i.ontology_leaf()) + .unwrap_or_else(|| ItemId::opaque(model.item.clone())); + div class="ont-parents-list" { + @for scope in &model.scopes { + @let strip = model.sibling_navs.iter().find(|s| s.scope == *scope); + @let weight = model.memberships.iter().find(|p| p.parent == *scope); + @let edge_id = edge_fragment_id(&item_id, scope); + div class="ont-parent-row" id=(edge_id) { + div class="ont-parent-head" { + a class="ont-parent-link" + href=(item_href(scope.as_str(), nav)) { + (item_display_path(scope.as_str())) + } + @if strip.is_none() { + span class="muted ont-parent-solo" { "only member" } + } + } + @if let Some(s) = strip { + (sibling_nav_markup(nav, &s.bar, model.item.as_str())) + } + div class="ont-edge-meta" { + @if let Some(w) = weight { + span class="muted ont-edge-weight" { (pair_weight_label(w)) } + } + a class="ont-parent-vote" + href=(vote_pool_href(nav, scope.as_str())) + title=(format!("vote among {} members", item_display_path(scope.as_str()))) { + "vote" + } + a class="ont-edge-link" + href=(format!("#{edge_id}")) + title="permalink to this membership" { + "§" + } } - " " - span class="muted" { (pair_weight_label(pair)) } } } } @@ -537,15 +630,18 @@ pub(super) fn build_item_page_view_model( .map(|id| id.ontology_leaf()) .unwrap_or_else(|| ItemId::parse("~/").unwrap()) .normalized_storage(); - let item_has_parent = !content.scopes_of(&item_key).is_empty(); let is_scope = !content.members_of(&item_key).is_empty(); let crumb_chain = containment_crumb_chain(content, &item_key); + // Active scopes, primary (strongest containment) first. Multi-parent items + // belong to several scopes at once — the old tree model kept only one. let primary_parent = strongest_parent(content, &item_key); - let alternate_scopes: Vec = content - .scopes_of(&item_key) - .into_iter() - .filter(|s| Some(s) != primary_parent.as_ref()) - .collect(); + let mut scopes = content.scopes_of(&item_key); + scopes.sort_by(|a, b| { + let primary_first = |s: &ItemId| if Some(s) == primary_parent.as_ref() { 0 } else { 1 }; + primary_first(a) + .cmp(&primary_first(b)) + .then_with(|| a.as_str().cmp(b.as_str())) + }); let pairs = child_border_pairs(content, &item_key); let memberships: Vec = pairs .iter() @@ -570,7 +666,7 @@ pub(super) fn build_item_page_view_model( } else { build_children_rankings(content, &item_key) }; - let sibling_nav = build_sibling_nav(reduced, scope, &item_key); + let sibling_navs = build_sibling_navs(reduced, scope, &item_key, &scopes); let rank_history = build_rank_history(reduced, scope, item_key.as_str()); @@ -588,11 +684,10 @@ pub(super) fn build_item_page_view_model( .get(&item_key) .cloned() .or_else(|| reduced.public().item_bodies.get(&item_key).cloned()), - sibling_nav, - item_has_parent, + sibling_navs, + scopes, is_scope, crumb_chain, - alternate_scopes, memberships, suspended_borders, fallen_journal, diff --git a/server/src/html/garden/question_body.rs b/server/src/html/garden/question_body.rs index e1fa4e86406656eb550f0851532f27e2ed07c0cf..ecba10068613dbeddd785dd70d07a810e691d208 100644 --- a/server/src/html/garden/question_body.rs +++ b/server/src/html/garden/question_body.rs @@ -13,6 +13,7 @@ use crate::{ }; use super::item::item_display_path; +use super::item_page::pair_weight_label; pub(super) fn aspects_for_scope( content: &ContentState, @@ -268,10 +269,7 @@ pub(super) fn question_members_markup( span { "suspended" } " " span { - (format!( - "containment {} · border {}", - st.containment_weight, st.border_weight - )) + (pair_weight_label(st)) } } } diff --git a/server/src/html/garden/render.rs b/server/src/html/garden/render.rs index 118adb17b41355c9f6eb12ca240986ccf3360ce0..f63968d324d781b9da9ff62dad86356b26ef4415 100644 --- a/server/src/html/garden/render.rs +++ b/server/src/html/garden/render.rs @@ -27,19 +27,22 @@ use super::{ item_href, }, item_page::{ - build_item_page_view_model, item_relations_markup, sibling_nav_markup, strongest_parent, - AspectRankingView, + build_item_page_view_model, fragment_slug, item_relations_markup, AspectRankingView, }, pin::{child_row_pin_or_vote, ont_pin_vote_controls, pinned_item_from_jar}, vote::vote_pool_href, }; +/// Ranking tables for one scope. `id_prefix` namespaces the per-row `#item-…` +/// anchors ("" for the canonical table, `"aspect--"` for aspect tables) +/// so each ranked edge is linkable from the parent page. pub(super) fn ont_ranking_lists_markup( rankings: &crate::scope_rank::ChildrenRankings, nav: &ThreadNav, pin_ref: Option<&(String, ItemId)>, scope_content: &ContentState, next_for_pin: &str, + id_prefix: &str, ) -> maud::Markup { html! { @for (ci, comp) in rankings.component_rankings.iter().enumerate() { @@ -51,7 +54,8 @@ pub(super) fn ont_ranking_lists_markup( @for r in comp.ranked.iter() { @let item_url = item_href(r.item.as_str(), nav); @let score_str = format!("{:.3}", r.score); - li data-garden-item=(r.item.as_str()) { + li data-garden-item=(r.item.as_str()) + id=(format!("{id_prefix}item-{}", fragment_slug(r.item.last_segment()))) { (child_row_pin_or_vote(nav, &r.item, pin_ref, scope_content, next_for_pin)) a class="item-link" href=(item_url) { code { (item_display_path(r.item.as_str())) } } span class="ont-rank-score" { (score_str) } @@ -65,7 +69,8 @@ pub(super) fn ont_ranking_lists_markup( div class="ont-group-meta" { "unranked" } ul class="ont-group-list" { @for name in &rankings.unranked_items { - li data-garden-item=(name.as_str()) { + li data-garden-item=(name.as_str()) + id=(format!("{id_prefix}item-{}", fragment_slug(name.last_segment()))) { (child_row_pin_or_vote(nav, name, pin_ref, scope_content, next_for_pin)) @let href = item_href(name.as_str(), nav); a class="item-link" href=(href) { code { (item_display_path(name.as_str())) } } @@ -91,12 +96,14 @@ pub(super) fn aspect_ranking_sections_markup( @if let Some(prompt) = &aspect.prompt { p class="muted ont-aspect-prompt" { (prompt) } } + @let aspect_prefix = format!("aspect-{}-", aspect.slug); (ont_ranking_lists_markup( &aspect.rankings, nav, pin_ref, scope_content, next_for_pin, + &aspect_prefix, )) } } @@ -139,21 +146,12 @@ pub(super) async fn render_scope_view( "view-ontology view-ontology-light", html! { nav class="breadcrumb" { (scoped_bc_path_for(&browse, &nav, &model.crumb_chain)) } - @if let Some(ref bar) = model.sibling_nav { - (sibling_nav_markup(&nav, bar, &model.item)) - } + // UP — parents above the body: the page flows parents → self → children. + (item_relations_markup(&model, &nav, now_ms())) section class="ont-item-shell" data-garden-item=(model.item.as_str()) { header class="ont-item-meta" { span class="ont-item-title" { (item_display_path(&model.item)) } - @if model.sibling_nav.is_none() && model.item_has_parent { - span class="muted ont-item-unranked-note" { "unranked among siblings" } - } - @let vote_item_href = model.sibling_nav.as_ref().and_then(|_| { - ItemId::parse(&model.item).and_then(|i| { - strongest_parent(scope_content, &i) - .map(|p| vote_pool_href(&nav, p.as_str())) - }) - }); + @let vote_item_href = model.scopes.first().map(|p| vote_pool_href(&nav, p.as_str())); (ont_pin_vote_controls( &nav, &model.item, @@ -294,13 +292,12 @@ pub(super) async fn render_scope_view( } } - (item_relations_markup(&model, &nav, now_ms())) - section class="ont-tab-panel ont-tab-panel-children" { @let total_children = model.child_rankings.component_rankings .iter().map(|c| c.ranked.len()).sum::() + model.child_rankings.unranked_items.len(); h3 { + span aria-hidden="true" { "↓ " } "ranked child groups" " " (garden_depth_select_markup(model.child_depth)) @@ -329,6 +326,7 @@ pub(super) async fn render_scope_view( pin_ref.as_ref(), scope_content, &next_for_pin, + "", )) } (aspect_ranking_sections_markup( diff --git a/server/src/html/garden/routes.rs b/server/src/html/garden/routes.rs index fb9c31df78ea411085ed35da9e5282fedb898db2..49e184f1cac1ae90045a101fa3d9f2da65187600 100644 --- a/server/src/html/garden/routes.rs +++ b/server/src/html/garden/routes.rs @@ -31,9 +31,10 @@ use super::{ }, browse::{scoped_bc_path_external, GardenBrowsePath}, copy::garden_rank_copy_button_markup, - item::{child_depth_from_uri, garden_depth_select_markup, item_display_path, item_href}, + item::{child_depth_from_uri, garden_depth_select_markup}, item_page::aspect_rankings_for_parent, - render::render_scope_view, + pin::pinned_item_from_jar, + render::{aspect_ranking_sections_markup, ont_ranking_lists_markup, render_scope_view}, }; /// 308 to `canonical`, keeping a non-empty query string. @@ -77,6 +78,35 @@ pub async fn garden_index( let url_key = canonical_view_url(&uri); let view_count = state.views.get_views(&url_key); + // Shared ranking-list markup (same helper as item pages): scores + pin/vote + // affordances, so the index never drifts from the scope view. + let pin_ref = pinned_item_from_jar(&jar); + let next_for_pin = match uri.query() { + Some(q) if !q.is_empty() => format!("/~?{q}"), + _ => "/~".to_string(), + }; + let (rankings_markup, aspects_markup) = { + let reduced = state.reduced.read().await; + let scope_content = content_for_garden_view(&reduced, &nav.scope()); + ( + ont_ranking_lists_markup( + &child_rankings, + &nav, + pin_ref.as_ref(), + scope_content, + &next_for_pin, + "", + ), + aspect_ranking_sections_markup( + &aspect_rankings, + &nav, + pin_ref.as_ref(), + scope_content, + &next_for_pin, + ), + ) + }; + let page = layout( "~/", "view-ontology view-ontology-light", @@ -95,58 +125,9 @@ pub async fn garden_index( @if child_rankings.component_rankings.is_empty() && child_rankings.unranked_items.is_empty() { p class="muted" { "no items yet" } } @else { - @for (ci, comp) in child_rankings.component_rankings.iter().enumerate() { - div class="ont-group-shell" { - div class="ont-group-meta" { - (format!("ordering {} items={} pairs={}", ci + 1, comp.ranked.len(), comp.pairs)) - } - ol class="ont-ranking-list" { - @for r in comp.ranked.iter() { - @let href = item_href(r.item.as_str(), &nav); - li { - a href=(href) { (item_display_path(r.item.as_str())) } - } - } - } - } - } - @if !child_rankings.unranked_items.is_empty() { - div class="ont-group-shell ont-group-unsorted" { - div class="ont-group-meta" { "unranked" } - ul class="ont-group-list" { - @for name in &child_rankings.unranked_items { - li { - @let href = item_href(name.as_str(), &nav); - a href=(href) { (item_display_path(name.as_str())) } - } - } - } - } - } - } - @for aspect in &aspect_rankings { - section class="ont-tab-panel ont-tab-panel-aspect" id=(format!("aspect-{}", aspect.slug)) { - h4 class="ont-aspect-heading" { ":" (aspect.slug) } - @if let Some(prompt) = &aspect.prompt { - p class="muted ont-aspect-prompt" { (prompt) } - } - @for (ci, comp) in aspect.rankings.component_rankings.iter().enumerate() { - div class="ont-group-shell" { - div class="ont-group-meta" { - (format!("ordering {} items={} pairs={}", ci + 1, comp.ranked.len(), comp.pairs)) - } - ol class="ont-ranking-list" { - @for r in comp.ranked.iter() { - @let href = item_href(r.item.as_str(), &nav); - li { - a href=(href) { (item_display_path(r.item.as_str())) } - } - } - } - } - } - } + (rankings_markup) } + (aspects_markup) (cli_panel(&["npx slugsocial garden tree"])) }, Some(view_count), @@ -198,6 +179,24 @@ pub async fn external_garden_index( let url_key = canonical_view_url(&uri); let view_count = state.views.get_views(&url_key); + let pin_ref = pinned_item_from_jar(&jar); + let next_for_pin = match uri.query() { + Some(q) if !q.is_empty() => format!("/-/?{q}"), + _ => "/-/".to_string(), + }; + let rankings_markup = { + let reduced = state.reduced.read().await; + let scope_content = content_for_garden_view(&reduced, &nav.scope()); + ont_ranking_lists_markup( + &child_rankings, + &nav, + pin_ref.as_ref(), + scope_content, + &next_for_pin, + "", + ) + }; + let page = layout( "-/", "view-ontology view-ontology-light", @@ -214,34 +213,7 @@ pub async fn external_garden_index( @if child_rankings.component_rankings.is_empty() && child_rankings.unranked_items.is_empty() { p class="muted" { "no external items indexed yet" } } @else { - @for (ci, comp) in child_rankings.component_rankings.iter().enumerate() { - div class="ont-group-shell" { - div class="ont-group-meta" { - (format!("ordering {} items={} pairs={}", ci + 1, comp.ranked.len(), comp.pairs)) - } - ol class="ont-ranking-list" { - @for r in comp.ranked.iter() { - @let href = item_href(r.item.as_str(), &nav); - li { - a href=(href) { (item_display_path(r.item.as_str())) } - } - } - } - } - } - @if !child_rankings.unranked_items.is_empty() { - div class="ont-group-shell ont-group-unsorted" { - div class="ont-group-meta" { "unranked" } - ul class="ont-group-list" { - @for name in &child_rankings.unranked_items { - li { - @let href = item_href(name.as_str(), &nav); - a href=(href) { (item_display_path(name.as_str())) } - } - } - } - } - } + (rankings_markup) } }, Some(view_count), @@ -343,6 +315,24 @@ pub async fn room_external_garden_index( let url_key = canonical_view_url(&uri); let view_count = state.views.get_views(&url_key); + let pin_ref = pinned_item_from_jar(&jar); + let next_for_pin = match uri.query() { + Some(q) if !q.is_empty() => format!("{}-/?{q}", nav.garden_root_url().trim_end_matches('~')), + _ => format!("{}-/", nav.garden_root_url().trim_end_matches('~')), + }; + let rankings_markup = { + let reduced = state.reduced.read().await; + let scope_content = content_for_garden_view(&reduced, &nav.scope()); + ont_ranking_lists_markup( + &child_rankings, + &nav, + pin_ref.as_ref(), + scope_content, + &next_for_pin, + "", + ) + }; + let page = layout( "-/", "view-ontology view-ontology-light", @@ -358,34 +348,7 @@ pub async fn room_external_garden_index( @if child_rankings.component_rankings.is_empty() && child_rankings.unranked_items.is_empty() { p class="muted" { "no external items indexed yet" } } @else { - @for (ci, comp) in child_rankings.component_rankings.iter().enumerate() { - div class="ont-group-shell" { - div class="ont-group-meta" { - (format!("ordering {} items={} pairs={}", ci + 1, comp.ranked.len(), comp.pairs)) - } - ol class="ont-ranking-list" { - @for r in comp.ranked.iter() { - @let href = item_href(r.item.as_str(), &nav); - li { - a href=(href) { (item_display_path(r.item.as_str())) } - } - } - } - } - } - @if !child_rankings.unranked_items.is_empty() { - div class="ont-group-shell ont-group-unsorted" { - div class="ont-group-meta" { "unranked" } - ul class="ont-group-list" { - @for name in &child_rankings.unranked_items { - li { - @let href = item_href(name.as_str(), &nav); - a href=(href) { (item_display_path(name.as_str())) } - } - } - } - } - } + (rankings_markup) } }, Some(view_count), diff --git a/server/src/html/garden/tests.rs b/server/src/html/garden/tests.rs index 29037d9287d326244f69741299fd46e8c8aff10c..76bb07d2faf3247ece49bd1ddfcce49cc82efbe9 100644 --- a/server/src/html/garden/tests.rs +++ b/server/src/html/garden/tests.rs @@ -4,8 +4,8 @@ use super::{ external::{external_frame_allowed, external_resolver_status_markup, external_source_href}, home::collect_question_entries, item_page::{ - build_item_page_view_model, containment_crumb_chain, item_relations_markup, - sibling_nav_markup, + build_item_page_view_model, containment_crumb_chain, edge_fragment_id, + item_relations_markup, pair_weight_label, sibling_nav_markup, }, pin::ont_pin_vote_controls, question::{collection_is_known, parse_collection_leaf, question_headline}, @@ -171,7 +171,8 @@ fn item_page_model_includes_body_and_unranked_without_votes() { let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1); assert_eq!(model.body.as_deref(), Some("alpha")); - assert!(model.sibling_nav.is_some()); + assert_eq!(model.sibling_navs.len(), 1); + assert_eq!(model.sibling_navs[0].scope.last_segment(), "topic"); assert!(model.child_rankings.component_rankings.is_empty()); } @@ -190,7 +191,8 @@ fn item_page_model_computes_sibling_nav_in_component() { ); let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1); - let nav = model.sibling_nav.expect("expected sibling nav"); + assert_eq!(model.sibling_navs.len(), 1); + let nav = &model.sibling_navs[0].bar; assert_eq!(nav.groups.len(), 2); assert_eq!(nav.groups[0].links.len(), 2); assert_eq!(nav.groups[1].links.len(), 1); @@ -219,18 +221,18 @@ fn sibling_nav_rank_score_only_for_largest_group_member() { // Largest component is a/b/c (size 3); d/e is size 2. let winner = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1); - let winner_nav = winner.sibling_nav.expect("sibling nav"); + let winner_nav = &winner.sibling_navs[0].bar; assert_eq!(winner_nav.largest_group_rank, Some((1, 3))); assert_eq!(winner_nav.winner_percentile, Some(66)); // (3-1)*100/3 let mid = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/b", 1); - let mid_nav = mid.sibling_nav.expect("sibling nav"); + let mid_nav = &mid.sibling_navs[0].bar; assert_eq!(mid_nav.largest_group_rank, Some((2, 3))); assert_eq!(mid_nav.winner_percentile, None); // Member of the smaller ranking group: no rank/percentile score. let small = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/d", 1); - let small_nav = small.sibling_nav.expect("sibling nav"); + let small_nav = &small.sibling_navs[0].bar; assert_eq!(small_nav.largest_group_rank, None); assert_eq!(small_nav.winner_percentile, None); } @@ -251,9 +253,9 @@ fn sibling_nav_markup_includes_rank_and_winner_percentile() { ); let winner = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1); - let winner_nav = winner.sibling_nav.expect("sibling nav"); + let winner_nav = &winner.sibling_navs[0].bar; let nav = ThreadNav::public(); - let html = sibling_nav_markup(&nav, &winner_nav, &winner.item).into_string(); + let html = sibling_nav_markup(&nav, winner_nav, &winner.item).into_string(); assert!(html.contains("rank: 1/3"), "missing rank in {html}"); assert!( html.contains("top 66th percentile"), @@ -261,8 +263,8 @@ fn sibling_nav_markup_includes_rank_and_winner_percentile() { ); let mid = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/b", 1); - let mid_nav = mid.sibling_nav.expect("sibling nav"); - let mid_html = sibling_nav_markup(&nav, &mid_nav, &mid.item).into_string(); + let mid_nav = &mid.sibling_navs[0].bar; + let mid_html = sibling_nav_markup(&nav, mid_nav, &mid.item).into_string(); assert!( mid_html.contains("rank: 2/3"), "missing mid rank in {mid_html}" @@ -289,7 +291,7 @@ fn sibling_nav_splits_each_unranked_into_its_own_group() { ); let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1); - let nav = model.sibling_nav.expect("expected sibling nav"); + let nav = &model.sibling_navs[0].bar; assert_eq!(nav.groups.len(), 3); assert_eq!(nav.groups[0].links.len(), 2); assert_eq!(nav.groups[1].links.len(), 1); @@ -482,7 +484,7 @@ fn item_page_room_scope_root_lists_top_level_children() { root.as_str(), 1, ); - assert!(!model.item_has_parent); + assert!(model.scopes.is_empty()); assert_eq!(model.child_rankings.unranked_items.len(), 2); let set: std::collections::HashSet<&str> = model .child_rankings @@ -638,8 +640,9 @@ fn item_page_sibling_nav_implies_vote_on_item_pool_href() { ~/topic/b {beta}\n", ); let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic/a", 1); - assert!( - model.sibling_nav.is_some(), + assert_eq!( + model.sibling_navs.len(), + 1, "expected sibling nav for ~/topic/a" ); let parent = ItemId::parse(&model.item) @@ -741,8 +744,9 @@ fn item_page_crumb_picks_strongest_parent_lists_alternates() { ); let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~luke", 1); - assert_eq!(model.alternate_scopes.len(), 1); - assert_eq!(model.alternate_scopes[0].last_segment(), "sith"); + assert_eq!(model.scopes.len(), 2); + assert_eq!(model.scopes[0].last_segment(), "jedi"); + assert_eq!(model.scopes[1].last_segment(), "sith"); } #[test] @@ -852,3 +856,78 @@ fn collect_question_entries_lists_scope_and_aspect_prompt() { "lonely has no members and must not appear" ); } + +/// An item with two active scopes gets one sibling nav per scope (primary first), +/// instead of the old single-parent nav that hid the second scope. +#[test] +fn item_page_builds_sibling_nav_per_scope() { + let mut reduced = ReducerState::default(); + apply_ingest( + &mut reduced, + 1, + "@00000000-0000-0000-0000-000000000000:test:local/test\n\ + ~jedi {j}\n~sith {s}\n~luke {l}\n~han {h}\n~maul {m}\n\ + { j1 }\n~luke <: ~jedi\n{ j2 }\n~han <: ~jedi\n\ + { s1 }\n~luke <: ~sith\n{ s2 }\n~maul <: ~sith\n", + ); + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~luke", 1); + assert_eq!(model.scopes.len(), 2); + assert_eq!(model.sibling_navs.len(), 2); + assert_eq!(model.sibling_navs[0].scope.last_segment(), "jedi"); + assert_eq!(model.sibling_navs[1].scope.last_segment(), "sith"); + // Each nav lists fellow members of that scope. + let jedi_links: Vec<&str> = model.sibling_navs[0].bar.groups[0].links + .iter() + .map(|l| l.path.as_str()) + .collect(); + assert!(jedi_links.iter().any(|p| p.ends_with("/han"))); + // The UP card merges one row per scope: parent link, position strip, + // vote home, and `#edge-…` permalink. + let html = item_relations_markup(&model, &ThreadNav::public(), 10_000).into_string(); + assert!(html.contains("/~/jedi"), "missing jedi scope row: {html}"); + assert!(html.contains("/~/sith"), "missing sith scope row: {html}"); + assert!( + html.contains("vote?pool="), + "missing per-scope vote home: {html}" + ); + assert!( + html.contains(&format!( + "id=\"{}\"", + edge_fragment_id( + &ItemId::parse("~luke").unwrap(), + &ItemId::parse("~jedi").unwrap() + ) + )), + "missing edge permalink id: {html}" + ); +} + +/// Weight labels show totals only: sugar counts the same as explicit `<:` +/// claims in the UI — sugar is not special there. (The reducer still tracks +/// the mix for idempotent path desugaring; see `ContainmentWeights`.) +#[test] +fn pair_weight_label_counts_sugar_like_explicit() { + let mut reduced = ReducerState::default(); + apply_ingest( + &mut reduced, + 1, + "~jedi { j }\n~luke { l }\n~han { h }\n{ in }\n~luke <: ~jedi\n", + ); + // ~/topic/han sugar edge only: ~/topic/han desugars to han <: topic + sugar. + apply_ingest( + &mut reduced, + 2, + "@00000000-0000-0000-0000-000000000000:test:local/test\n~/topic {t}\n~/topic/han {h}\n", + ); + let content = content_for_garden_view(&reduced, &ScopeId::Public); + let luke = ItemId::parse("~luke").unwrap(); + let jedi = ItemId::parse("~jedi").unwrap(); + let st = content.border_state(&luke, &jedi).expect("luke/jedi pair"); + assert_eq!(pair_weight_label(&st), "containment 1 · border 0"); + + let han = ItemId::parse("~han").unwrap(); + let topic = ItemId::parse("~topic").unwrap(); + let sugar_st = content.border_state(&han, &topic).expect("han/topic pair"); + assert!(sugar_st.sugar); + assert_eq!(pair_weight_label(&sugar_st), "containment 1 · border 0"); +} diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 092ee21c2fa46db9ab328accd9dd67fdd474c210..3bdec00a4ff121369ff87284c0518c237c6e7352 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -595,16 +595,13 @@ fn bc_path_external(path: &ExternalOntologyPath) -> Markup { } } -/// Breadcrumb for any ontology path segment, e.g. "parables" or "parables/counting-the-cost". +/// Breadcrumb for the garden root (`/~`, the only caller). Deeper tilde pages render +/// the containment breadcrumb (`scoped_bc_containment`); URL segments are not +/// hierarchy — nested inputs redirect to the leaf before any page renders. fn bc_path(path: &OntologyPath) -> Markup { html! { a href=(path.slug_root_href()) { "slug.social" } - (bc_segment("~", "/~", path.is_root())) - @for (i, seg) in path.segments().iter().enumerate() { - @let href = format!("/~/{}", path.segments()[..=i].join("/")); - @let is_last = i == path.segments().len() - 1; - (bc_segment(seg, &href, is_last)) - } + (bc_segment("~", "/~", true)) } } diff --git a/server/src/reducer.rs b/server/src/reducer.rs index c6b0b9d468014b557dacbd08c52eb061497b9d5b..078667011c08d2049ca860ea611226b819234e91 100644 --- a/server/src/reducer.rs +++ b/server/src/reducer.rs @@ -170,6 +170,10 @@ pub struct BorderPairState { pub child: ItemId, pub parent: ItemId, pub containment_weight: u32, + /// Explicit `<:` claims (accumulate). Sugar (`~/a/b` desugar) adds at most 1 (see `sugar`). + pub explicit: u32, + /// Whether idempotent `~/a/b` path sugar contributed (worth exactly 1). + pub sugar: bool, pub border_weight: u32, pub status: MembershipStatus, } @@ -371,11 +375,8 @@ impl ContentState { let child = child.ontology_leaf(); let parent = parent.ontology_leaf(); let pair = (child.clone(), parent.clone()); - let c_w = self - .containment - .get(&pair) - .map(ContainmentWeights::containment_weight) - .unwrap_or(0); + let weights = self.containment.get(&pair).cloned().unwrap_or_default(); + let c_w = weights.containment_weight(); let b_w = self.borders.get(&pair).copied().unwrap_or(0); if c_w == 0 && b_w == 0 { return None; @@ -389,6 +390,8 @@ impl ContentState { child, parent, containment_weight: c_w, + explicit: weights.explicit, + sugar: weights.sugar, border_weight: b_w, status, }) diff --git a/server/static/theme_default.css b/server/static/theme_default.css index 7e5d610aa413880208ac2ea577d4263022d4fae8..969710b8a2241b9b5e8c8c7abedd0bc19c876b30 100644 --- a/server/static/theme_default.css +++ b/server/static/theme_default.css @@ -1989,3 +1989,329 @@ details.question-row .vote-compare-shell { margin: 0 8px 10px; max-width: none; } + +/* ================================================================ + GARDEN HIERARCHY — above / here / below (paper/light only) + Problem: breadcrumb, sibling strip, current item, membership + jargon, and child rankings all shared one beige bevel at one + weight, with fit-content ranking rows forming a staircase. + This pass establishes: muted UP strip, hero HERE card, + de-emphasized LATERAL, and an indented DOWN ranking table. + ================================================================ */ +body.view-ontology.view-ontology-light nav.breadcrumb { + border-width: 1px; +} +body.view-ontology.view-ontology-light nav.breadcrumb a.bc-current { + font-weight: 700; +} +/* Sibling scope label: micro caps, clearly "up / lateral" */ +body.view-ontology.view-ontology-light p.ont-sibling-scope-label { + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + margin: 10px 0 2px; +} +body.view-ontology.view-ontology-light p.ont-sibling-scope-label a { + text-transform: none; + letter-spacing: normal; + font-size: 12px; +} +/* Sibling number strip: flatten to an inset subordinate ribbon */ +body.view-ontology.view-ontology-light nav.breadcrumb.ont-sibling-nav { + background: var(--g1); + border: 1px solid var(--lo); + font-size: 12px; + margin-top: 2px; + padding: 2px 8px; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} +body.view-ontology.view-ontology-light nav.breadcrumb.ont-sibling-nav a { + display: inline-block; + min-width: 1.4em; + text-align: center; + padding: 0 3px; + border-radius: 3px; + color: var(--ui); +} +body.view-ontology.view-ontology-light nav.breadcrumb.ont-sibling-nav a:hover { + background: var(--g4); + color: var(--signal); +} +/* Current position in the sibling order: filled pill, unmissable */ +body.view-ontology.view-ontology-light nav.breadcrumb.ont-sibling-nav a[aria-current="page"] { + background: var(--link); + color: #fff; + font-weight: 700; +} +body.view-ontology.view-ontology-light div.ont-sibling-nav-score { + font-size: 11px; + margin: 2px 0 0; +} +body.view-ontology.view-ontology-light span.ont-sibling-nav-rank { + background: var(--g4); + border: 1px solid var(--lo); + border-radius: 3px; + padding: 0 6px; + font-weight: 700; + color: var(--signal); +} +/* HERE: current item is the hero — white card, mid-gradient edge. + Full blue belongs to the parents above; the hero steps down. */ +body.view-ontology.view-ontology-light .ont-item-shell { + background: var(--g3); + border: 1px solid var(--lo); + border-left: 6px solid color-mix(in srgb, var(--link) 55%, var(--lo)); + box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.08); +} +body.view-ontology.view-ontology-light .ont-item-meta { + background: transparent; + border-bottom: 1px solid var(--lo); + padding: 8px 12px; +} +body.view-ontology.view-ontology-light .ont-item-title { + font-size: 15px; +} +body.view-ontology.view-ontology-light .ont-item-content { + padding: 10px 12px; +} +/* Section dividers: every garden section shares one header voice; + direction comes from the ↑ / ↓ glyphs in the markup. */ +body.view-ontology.view-ontology-light section.ont-tab-panel { + margin-top: 6px; +} +body.view-ontology.view-ontology-light section.ont-tab-panel > h3 { + border-top: 2px solid var(--lo); + margin-top: 26px; + padding-top: 10px; + font-size: 11px; + color: var(--ui); +} +/* UP card: same card chrome as the HERE hero. + FLOW GRADIENT: the left edge descends parents → self → children from + full blue to grey, so the hierarchy reads as downward flow. Parents are + most blue; each step down fades. */ +body.view-ontology.view-ontology-light section.ont-tab-panel-memberships { + background: var(--g3); + border: 1px solid var(--lo); + border-left: 6px solid var(--link); + box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.08); + margin: 8px 0 14px; + padding: 0 0 4px; + width: 100%; + min-width: 100%; + max-width: 100%; + box-sizing: border-box; +} +body.view-ontology.view-ontology-light section.ont-tab-panel-memberships > h3 { + border-top: none; + margin-top: 0; + padding: 8px 12px; + background: transparent; + border-bottom: 1px solid var(--lo); +} +/* One row per parent scope: head line, position strip, meta footnote. */ +body.view-ontology.view-ontology-light .ont-parents-list { + display: flex; + flex-direction: column; +} +body.view-ontology.view-ontology-light .ont-parent-row { + padding: 8px 12px 10px; + border-bottom: 1px solid var(--g4); + scroll-margin-top: 12px; +} +body.view-ontology.view-ontology-light .ont-parent-row:last-child { + border-bottom: none; +} +body.view-ontology.view-ontology-light .ont-parent-row:target { + background: color-mix(in srgb, var(--link) 8%, transparent); +} +body.view-ontology.view-ontology-light .ont-parent-head { + display: flex; + align-items: baseline; + gap: 8px; +} +body.view-ontology.view-ontology-light a.ont-parent-link { + font-size: 13px; + font-weight: 700; +} +body.view-ontology.view-ontology-light .ont-parent-solo { + font-size: 11px; +} +/* Edge meta row: weight footnote left, vote home + permalink right — + kept off the title line so the parent name always stands alone. */ +body.view-ontology.view-ontology-light .ont-edge-meta { + display: flex; + align-items: baseline; + gap: 8px; + margin: 4px 0 0; +} +body.view-ontology.view-ontology-light .ont-edge-weight { + font-size: 11px; +} +body.view-ontology.view-ontology-light a.ont-parent-vote { + margin-left: auto; + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} +body.view-ontology.view-ontology-light a.ont-edge-link { + color: var(--meta); + font-size: 13px; + text-decoration: none; + padding: 0 2px; +} +body.view-ontology.view-ontology-light a.ont-edge-link:hover { + color: var(--link); +} +body.view-ontology.view-ontology-light .ont-parent-row .ont-sibling-nav-wrap { + margin: 6px 0 0; +} +/* Suspended borders + fallen journal live in the UP card (edge states). */ +body.view-ontology.view-ontology-light section.ont-tab-panel-memberships section.ont-tab-panel-borders { + margin: 4px 0 0; + padding: 0 12px; +} +body.view-ontology.view-ontology-light section.ont-tab-panel-memberships section.ont-tab-panel-borders > h3 { + border-top: 1px dashed var(--lo); + margin-top: 10px; + padding-top: 8px; +} +body.view-ontology.view-ontology-light section.ont-tab-panel-memberships details.ont-fallen-borders { + margin: 8px 12px 6px; +} +/* Children heading controls: keep the vote action on one line, primary */ +body.view-ontology.view-ontology-light section.ont-tab-panel-children > h3 { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; +} +body.view-ontology.view-ontology-light a.ont-vote-children-btn { + white-space: nowrap; + color: var(--link); + font-weight: 700; + text-decoration: none; +} +body.view-ontology.view-ontology-light a.ont-vote-children-btn:hover { + text-decoration: underline; +} +/* Aspect sub-sections: same DOWN treatment as child groups */ +body.view-ontology.view-ontology-light section.ont-tab-panel-aspect { + border-left: 2px solid var(--lo); + padding-left: 12px; + margin-left: 2px; + margin-top: 18px; +} +body.view-ontology.view-ontology-light h4.ont-aspect-heading { + color: var(--ui); + border-top: 1px dashed var(--lo); + margin-top: 0; + padding-top: 10px; +} +body.view-ontology.view-ontology-light h4.ont-aspect-heading::before { + content: "\25BC "; + color: var(--meta); + font-size: 10px; +} +/* DOWN: children live under a tree-trunk rule, full-width rows. + The trunk carries the last breath of blue before the table greys out. */ +body.view-ontology.view-ontology-light section.ont-tab-panel-children { + border-left: 4px solid color-mix(in srgb, var(--link) 22%, var(--lo)); + padding-left: 12px; + margin-left: 2px; +} +body.view-ontology.view-ontology-light section.ont-tab-panel-children > h3 { + border-top: none; + margin-top: 18px; + padding-top: 0; +} +body.view-ontology.view-ontology-light .ont-group-shell, +body.view-ontology.view-ontology-light .ont-group-shell.ont-group-unsorted { + border: 1px solid var(--lo); + width: 100%; + min-width: 100%; + max-width: 100%; + box-sizing: border-box; +} +body.view-ontology.view-ontology-light .ont-group-meta { + background: transparent; + border-bottom: 1px solid var(--lo); + color: var(--meta); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +/* Ranking rows: table-like, names lead, scores trail muted. + NOTE: generic `ul:not(.thread-feed):not(.ranking)` forces + `width: fit-content` (staircase). Unranked lists are
    , so + override width here or single-child shells stay narrow. */ +body.view-ontology.view-ontology-light ol.ont-ranking-list, +body.view-ontology.view-ontology-light ul.ont-group-list { + padding: 6px; + width: 100%; + max-width: 100%; + box-sizing: border-box; + align-items: stretch; +} +body.view-ontology.view-ontology-light ol.ont-ranking-list li, +body.view-ontology.view-ontology-light ul.ont-group-list li { + width: 100%; + max-width: none; + box-sizing: border-box; + flex: 1 1 auto; + background: var(--g3); + border: 1px solid var(--lo); + border-left: 4px solid var(--lo); + border-radius: 2px; + padding: 6px 10px; +} +/* DOWN keeps rank order readable, but capped below the hero so the + section gradient keeps descending: parents 100 → hero 55 → rows ≤40. */ +body.view-ontology.view-ontology-light ol.ont-ranking-list li:nth-child(1) { + border-left-color: color-mix(in srgb, var(--link) 40%, var(--lo)); +} +body.view-ontology.view-ontology-light ol.ont-ranking-list li:nth-child(2) { + border-left-color: color-mix(in srgb, var(--link) 25%, var(--lo)); +} +body.view-ontology.view-ontology-light ol.ont-ranking-list li:nth-child(3) { + border-left-color: color-mix(in srgb, var(--link) 15%, var(--lo)); +} +body.view-ontology.view-ontology-light .ont-ranking-list li::before { + font-size: 0.85rem; + font-weight: 700; + color: var(--ui); + min-width: 3ch; +} +body.view-ontology.view-ontology-light .ont-group-list li a.item-link, +body.view-ontology.view-ontology-light .ont-ranking-list li a.item-link { + font-size: 13px; + font-weight: 600; +} +body.view-ontology.view-ontology-light .ont-group-list li a.item-link code, +body.view-ontology.view-ontology-light .ont-ranking-list li a.item-link code { + font-size: 13px; + border: none; + background: none; + padding: 0; + color: var(--signal); +} +body.view-ontology.view-ontology-light .ont-rank-score { + font-size: 0.8rem; + font-weight: 400; + font-variant-numeric: tabular-nums; + color: var(--meta); + margin-left: auto; + white-space: nowrap; +} +/* Jump targets: edge links from the other side of the relation land here. */ +body.view-ontology.view-ontology-light ol.ont-ranking-list li, +body.view-ontology.view-ontology-light ul.ont-group-list li { + scroll-margin-top: 12px; +} +body.view-ontology.view-ontology-light ol.ont-ranking-list li:target, +body.view-ontology.view-ontology-light ul.ont-group-list li:target { + outline: 2px solid var(--link); + outline-offset: 1px; +} diff --git a/server/static/theme_retro.css b/server/static/theme_retro.css index f20bb1e59c73931a6f86e20bb2759ae08e9ec402..83051ea47dd6babe2b69b869eb9b8144f26524fc 100644 --- a/server/static/theme_retro.css +++ b/server/static/theme_retro.css @@ -459,3 +459,175 @@ body.view-ontology .vote-compare-right .vote-compare-item-body .item-body-rich a details.question-row > summary { cursor: pointer; } .question-row-links { font-size: 13px; } + +/* ================================================================ + GARDEN HIERARCHY — parents ↑ / self / children ↓ (paper context). + Lean retro version of the same system in the other themes: + FLOW GRADIENT on the left edge, parents most blue → hero mid → + children grey. Structural flex rules included so per-edge vote/§ + links never glue onto parent titles. + ================================================================ */ +body.view-ontology section.ont-tab-panel > h3 { + font-size: 0.75rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #333; + border-top: 2px solid #ccc; + margin: 1.5rem 0 0.5rem; + padding-top: 0.6rem; +} +body.view-ontology h4.ont-aspect-heading { + color: #555; +} +/* UP card: modest box, full-blue ascent edge. */ +body.view-ontology section.ont-tab-panel-memberships { + background: #faf8f3; + border: 1px solid #ccc; + border-left: 6px solid #23a; + margin: 0.5rem 0 1rem; + padding: 0 0 0.25rem; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} +body.view-ontology section.ont-tab-panel-memberships > h3 { + border-top: none; + margin-top: 0; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid #ccc; +} +body.view-ontology .ont-parents-list { + display: flex; + flex-direction: column; +} +body.view-ontology .ont-parent-row { + padding: 0.5rem 0.65rem 0.65rem; + border-bottom: 1px solid #e3ded4; + scroll-margin-top: 0.75rem; +} +body.view-ontology .ont-parent-row:last-child { + border-bottom: none; +} +body.view-ontology .ont-parent-row:target { + background: color-mix(in srgb, #23a 8%, transparent); +} +body.view-ontology .ont-parent-head { + display: flex; + align-items: baseline; + gap: 0.5rem; +} +body.view-ontology a.ont-parent-link { + font-weight: 600; + color: #111; + text-decoration: none; +} +body.view-ontology a.ont-parent-link:hover { + color: #23a; + text-decoration: underline; +} +body.view-ontology .ont-parent-solo { + font-size: 0.78rem; +} +/* Edge meta row: weight footnote left, vote home + permalink right. */ +body.view-ontology .ont-edge-meta { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-top: 0.25rem; +} +body.view-ontology .ont-edge-weight { + font-size: 0.78rem; + color: #666; +} +body.view-ontology a.ont-parent-vote { + margin-left: auto; + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; +} +body.view-ontology a.ont-edge-link { + color: #888; + text-decoration: none; + padding: 0 0.15rem; +} +body.view-ontology a.ont-edge-link:hover { + color: #23a; +} +body.view-ontology .ont-parent-row .ont-sibling-nav-wrap { + margin-top: 0.4rem; +} +/* Current position in the sibling order. */ +body.view-ontology nav.breadcrumb.ont-sibling-nav a[aria-current="page"] { + background: #23a; + color: #fff; + border-radius: 2px; + padding: 0 0.25em; +} +/* Suspended borders + fallen journal live in the UP card. */ +body.view-ontology section.ont-tab-panel-memberships section.ont-tab-panel-borders { + margin: 0; + padding: 0 0.65rem; +} +body.view-ontology section.ont-tab-panel-memberships section.ont-tab-panel-borders > h3 { + border-top: 1px dashed #ccc; + margin-top: 0.65rem; + padding-top: 0.5rem; +} +body.view-ontology section.ont-tab-panel-memberships details.ont-fallen-borders { + margin: 0.5rem 0.65rem 0.4rem; +} +/* HERE hero: no box (lean), stepped-down edge. */ +body.view-ontology .ont-item-shell { + border-left: 6px solid color-mix(in srgb, #23a 55%, #ccc); + padding-left: 0.65rem; + margin: 0.5rem 0 1rem; +} +/* DOWN: children under a grey trunk, full-width rows, muted scores. */ +body.view-ontology section.ont-tab-panel-children { + border-left: 4px solid #ccc; + padding-left: 0.65rem; + margin-left: 0.1rem; +} +body.view-ontology section.ont-tab-panel-children > h3 { + border-top: none; + margin-top: 1rem; + padding-top: 0; +} +body.view-ontology section.ont-tab-panel-aspect { + border-left: 2px solid #ccc; + padding-left: 0.65rem; + margin-left: 0.1rem; + margin-top: 1rem; +} +body.view-ontology .ont-group-meta { + color: #888; + font-size: 0.7rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} +body.view-ontology ol.ont-ranking-list, +body.view-ontology ul.ont-group-list { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} +body.view-ontology ol.ont-ranking-list li, +body.view-ontology ul.ont-group-list li { + width: 100%; + max-width: none; + box-sizing: border-box; + scroll-margin-top: 0.75rem; +} +body.view-ontology ol.ont-ranking-list li:target, +body.view-ontology ul.ont-group-list li:target { + outline: 2px solid #23a; + outline-offset: 1px; +} +body.view-ontology .ont-rank-score { + margin-left: auto; + font-size: 0.78rem; + font-weight: 400; + font-variant-numeric: tabular-nums; + color: #888; + white-space: nowrap; +} diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css index 6603aa89faf4e62b7a503cd3cc17e6763d3266f7..42b3662f5fdcd22818e107ad131e89567cf157c2 100644 --- a/server/static/theme_retro_craft.css +++ b/server/static/theme_retro_craft.css @@ -555,6 +555,13 @@ nav.breadcrumb.ont-sibling-nav span.ont-sibling-nav-group-sep { font-size: 0.9em; line-height: 1.3; } +/* Current position in the sibling order (cream context). */ +body.view-ontology nav.breadcrumb.ont-sibling-nav a[aria-current="page"] { + background: #1a4a8c; + color: #fff; + border-radius: 2px; + padding: 0 0.25em; +} body.view-ontology { background: #ebe6dc; @@ -1290,3 +1297,199 @@ details.question-row { padding: 0.2rem 0.5rem; } details.question-row > summary { cursor: pointer; } + +/* ---------------------------------------------------------------- + Garden hierarchy: parents ↑ / self / children ↓ (cream context). + Craft headings use light --ink for dark thread pages, which vanishes + on cream ontology pages — every rule below is scoped to + body.view-ontology and re-pins dark-cream-ink explicitly. + ---------------------------------------------------------------- */ +body.view-ontology section.ont-tab-panel > h3 { + color: #3d3a34; + font-family: var(--font-ui); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + border-top: 2px solid #c8c4bc; + margin: 1.5rem 0 0.5rem; + padding-top: 0.6rem; +} +body.view-ontology h4.ont-aspect-heading { + color: #5c574e; +} +/* UP card: same chrome as the hero, neutral ascent edge. */ +/* FLOW GRADIENT (all themes): parents most blue → hero mid → children grey. */ +body.view-ontology section.ont-tab-panel-memberships { + background: #f7f3eb; + border: 1px solid #c8c4bc; + border-left: 6px solid #1a4a8c; + box-shadow: 0 1px 0 rgba(26, 24, 20, 0.06); + margin: 0.5rem 0 1rem; + padding: 0 0 0.25rem; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} +body.view-ontology section.ont-tab-panel-memberships > h3 { + border-top: none; + margin-top: 0; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid #c8c4bc; +} +body.view-ontology .ont-parents-list { + display: flex; + flex-direction: column; +} +body.view-ontology .ont-parent-row { + padding: 0.5rem 0.65rem 0.65rem; + border-bottom: 1px solid #e3ded4; + scroll-margin-top: 0.75rem; +} +body.view-ontology .ont-parent-row:last-child { + border-bottom: none; +} +body.view-ontology .ont-parent-row:target { + background: color-mix(in srgb, #1a4a8c 8%, transparent); +} +body.view-ontology .ont-parent-head { + display: flex; + align-items: baseline; + gap: 0.5rem; +} +body.view-ontology a.ont-parent-link { + font-weight: 600; + color: #1a1814; + text-decoration: none; +} +body.view-ontology a.ont-parent-link:hover { + color: #1a4a8c; + text-decoration: underline; +} +body.view-ontology .ont-parent-solo { + font-size: 0.78rem; +} +/* Edge meta row: weight footnote left, vote home + permalink right. */ +body.view-ontology .ont-edge-meta { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-top: 0.25rem; +} +body.view-ontology .ont-edge-weight { + font-size: 0.78rem; + color: #6a655c; +} +body.view-ontology a.ont-parent-vote { + margin-left: auto; + font-family: var(--font-ui); + font-size: 0.72rem; + font-weight: 600; + white-space: nowrap; +} +body.view-ontology a.ont-edge-link { + color: #8a857a; + text-decoration: none; + padding: 0 0.15rem; +} +body.view-ontology a.ont-edge-link:hover { + color: #1a4a8c; +} +body.view-ontology .ont-parent-row .ont-sibling-nav-wrap { + margin-top: 0.4rem; +} +/* Suspended borders + fallen journal live in the UP card. */ +body.view-ontology section.ont-tab-panel-memberships section.ont-tab-panel-borders { + margin: 0; + padding: 0 0.65rem; +} +body.view-ontology section.ont-tab-panel-memberships section.ont-tab-panel-borders > h3 { + border-top: 1px dashed #c8c4bc; + margin-top: 0.65rem; + padding-top: 0.5rem; +} +body.view-ontology section.ont-tab-panel-memberships details.ont-fallen-borders { + margin: 0.5rem 0.65rem 0.4rem; +} +/* HERE hero: same card, stepped-down edge — full blue belongs to parents. */ +body.view-ontology .ont-item-shell { + border-left: 6px solid color-mix(in srgb, #1a4a8c 55%, #c8c4bc); + box-shadow: 0 1px 0 rgba(26, 24, 20, 0.06); +} +/* DOWN: same widths as the default theme (2px trunk, 4px row edges) so the + gradient survives the minimal craft rows. Rank fade capped below the hero. */ +body.view-ontology section.ont-tab-panel-children { + border-left: 4px solid #c8c4bc; + padding-left: 0.65rem; + margin-left: 0.1rem; +} +body.view-ontology section.ont-tab-panel-aspect { + border-left: 2px solid #c8c4bc; + padding-left: 0.65rem; + margin-left: 0.1rem; +} +body.view-ontology section.ont-tab-panel-children > h3 { + border-top: none; + margin-top: 1rem; + padding-top: 0; +} +body.view-ontology ol.ont-ranking-list li, +body.view-ontology ul.ont-group-list li { + border-left: 4px solid #c8c4bc; + padding-left: 0.5rem; +} +body.view-ontology ol.ont-ranking-list li:nth-child(1) { + border-left-color: color-mix(in srgb, #1a4a8c 40%, #c8c4bc); +} +body.view-ontology ol.ont-ranking-list li:nth-child(2) { + border-left-color: color-mix(in srgb, #1a4a8c 25%, #c8c4bc); +} +body.view-ontology ol.ont-ranking-list li:nth-child(3) { + border-left-color: color-mix(in srgb, #1a4a8c 15%, #c8c4bc); +} +/* DOWN: children under a trunk rule, full-width rows, muted scores. */ +body.view-ontology section.ont-tab-panel-children { + border-left: 2px solid #c8c4bc; + padding-left: 0.65rem; + margin-left: 0.1rem; +} +body.view-ontology section.ont-tab-panel-children > h3 { + border-top: none; + margin-top: 1rem; + padding-top: 0; +} +body.view-ontology section.ont-tab-panel-children > h3 { + color: #3d3a34; +} +body.view-ontology .ont-group-meta { + color: #8a857a !important; + font-size: 0.68rem !important; + letter-spacing: 0.1em; + text-transform: uppercase; +} +body.view-ontology ol.ont-ranking-list, +body.view-ontology ul.ont-group-list { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} +body.view-ontology ol.ont-ranking-list li, +body.view-ontology ul.ont-group-list li { + width: 100%; + max-width: none; + box-sizing: border-box; + scroll-margin-top: 0.75rem; +} +body.view-ontology ol.ont-ranking-list li:target, +body.view-ontology ul.ont-group-list li:target { + outline: 2px solid #1a4a8c; + outline-offset: 1px; +} +body.view-ontology .ont-rank-score { + margin-left: auto; + font-size: 0.78rem; + font-weight: 400; + font-variant-numeric: tabular-nums; + color: #8a857a; + white-space: nowrap; +}