diff --git a/Cargo.toml b/Cargo.toml index 9bc7a4f6..a733e024 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["network-programming", "web-programming"] exclude.workspace = true [workspace.package] -version = "0.28.19" +version = "0.28.20" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] @@ -38,13 +38,13 @@ resolver = "3" crate-type = ["rlib"] [workspace.dependencies] -euv = { path = ".", version = "0.28.19" } -euv-ui = { path = "ui", version = "0.28.19" } -euv-cli = { path = "cli", version = "0.28.19" } -euv-core = { path = "core", version = "0.28.19" } -euv-engine = { path = "engine", version = "0.28.19" } -euv-macros = { path = "macros", version = "0.28.19" } -euv-example = { path = "example", version = "0.28.19" } +euv = { path = ".", version = "0.28.20" } +euv-ui = { path = "ui", version = "0.28.20" } +euv-cli = { path = "cli", version = "0.28.20" } +euv-core = { path = "core", version = "0.28.20" } +euv-engine = { path = "engine", version = "0.28.20" } +euv-macros = { path = "macros", version = "0.28.20" } +euv-example = { path = "example", version = "0.28.20" } log = "0.4.34" toml = "0.9.12" diff --git a/docs/src/app/const.rs b/docs/src/app/const.rs index ba6ce77f..646433f7 100644 --- a/docs/src/app/const.rs +++ b/docs/src/app/const.rs @@ -68,22 +68,24 @@ pub(crate) const APP_GLOBAL_CSS: &str = "html, body { height: 100% !important; o .c_euv_sidebar_group_title_root:hover { background: var(--muted, #f4f4f5) !important; color: var(--foreground, #000) !important; } \ .c_euv_sidebar_group_title_root_active, .c_euv_sidebar_group_title_root_active:hover { background: var(--accent) !important; color: var(--text-on-accent) !important; } \ .c_theme_dark .c_euv_sidebar_group_title_root:hover { background: var(--muted, #27272a) !important; color: var(--foreground, #fff) !important; } \ - .c_euv_sidebar_link_active_flush, .c_euv_sidebar_group_title_active { position: relative !important; margin-left: calc(-1 * (8px + 8px + 1px)) !important; width: calc(100% + 8px + 8px + 1px) !important; } \ + .c_euv_sidebar_link_active_flush, .c_euv_sidebar_group_title_active { position: relative !important; margin-left: -9px !important; width: calc(100% + 9px) !important; padding-left: calc(12px + 9px) !important; } \ .c_euv_sidebar_link_active_flush::before, .c_euv_sidebar_group_title_active::before { left: 0 !important; } \ .c_euv_sidebar_link_active_flush:hover::before { background: var(--accent, #000) !important; } \ \ - .c_euv_doc_layout { max-width: 1160px !important; display: flex !important; flex-direction: row !important; width: 100% !important; } \ + .c_euv_doc_layout { max-width: 1160px !important; display: flex !important; flex-direction: row !important; width: 100% !important; flex-shrink: 0 !important; } \ /* `min-height: 100%` fills the visible area of `c_app_main` so the tail can be pushed to the bottom; a viewport unit overshoots by the header. `space-between` puts the tail at the bottom when the article is short and lets it follow the article when the article is long. Not sticky, not fixed: the tail scrolls with the page. */ \ .c_euv_doc_content { display: flex !important; flex-direction: column !important; flex: 1 !important; justify-content: space-between !important; } \ .c_euv_doc_content article.md-body { display: block !important; flex: 0 0 auto !important; min-height: 0 !important; overflow: visible !important; } \ .c_euv_doc_content article.md-body > div { display: block !important; min-height: 0 !important; } \ .c_euv_doc_tail { display: block !important; flex: 0 0 auto !important; } \ - .c_euv_doc_toc { width: 280px !important; flex-shrink: 0 !important; position: sticky !important; top: 0 !important; align-self: flex-start !important; max-height: 100% !important; overflow-y: auto !important; } \ + .c_euv_doc_toc { width: 280px !important; flex-shrink: 0 !important; position: sticky !important; top: var(--padding-main-top, 24px) !important; align-self: flex-start !important; max-height: 100% !important; overflow-y: auto !important; } \ .c_euv_toc_link_nested { padding-left: 0.75rem !important; font-size: var(--font-sm, 0.875rem) !important; color: var(--muted-foreground, #555) !important; line-height: 1.5 !important; } \ .c_euv_toc_link, .c_euv_toc_link_nested { font-weight: 400 !important; } \ - .c_euv_toc_link:hover, .c_euv_toc_link_nested:hover { color: var(--accent, #000) !important; font-weight: 700 !important; } \ + .c_euv_toc_link:hover, .c_euv_toc_link_nested:hover { color: var(--accent, #000) !important; font-weight: 400 !important; } \ .c_euv_toc_link_active, .c_euv_toc_link_nested_active { color: var(--accent, #000) !important; font-weight: 700 !important; } \ - .c_theme_dark .c_euv_toc_link:hover, .c_theme_dark .c_euv_toc_link_nested:hover, .c_theme_dark .c_euv_toc_link_active, .c_theme_dark .c_euv_toc_link_nested_active { color: var(--accent, #fff) !important; font-weight: 700 !important; } \ + .c_euv_toc_link_active:hover, .c_euv_toc_link_nested_active:hover { color: var(--accent, #000) !important; font-weight: 700 !important; } \ + .c_theme_dark .c_euv_toc_link:hover, .c_theme_dark .c_euv_toc_link_nested:hover { color: var(--accent, #fff) !important; font-weight: 400 !important; } \ + .c_theme_dark .c_euv_toc_link_active, .c_theme_dark .c_euv_toc_link_nested_active, .c_theme_dark .c_euv_toc_link_active:hover, .c_theme_dark .c_euv_toc_link_nested_active:hover { color: var(--accent, #fff) !important; font-weight: 700 !important; } \ \ .c_euv_pagination { padding-bottom: var(--space-xl, 1.25rem) !important; gap: var(--gap-component, 1rem) !important; flex-wrap: nowrap !important; align-items: stretch !important; width: 100% !important; } \ .c_euv_pagination_link { padding: var(--space-md, 0.75rem) !important; gap: var(--space-2xs, 0.25rem) !important; min-width: 0 !important; max-width: none !important; } \ diff --git a/ui/src/style/class/display/fn.rs b/ui/src/style/class/display/fn.rs index 14a2465b..2ec857bf 100644 --- a/ui/src/style/class/display/fn.rs +++ b/ui/src/style/class/display/fn.rs @@ -1016,6 +1016,14 @@ class! { // the intermediate auto-height flex row makes `100%` degenerate to // `auto`. min-height: "100%"; + // As a flex item of the scroll container's column, the default + // `flex-shrink: 1` lets this row be compressed to the `min-height` + // floor — 824px here — even though its content is 12780px tall. A + // sticky descendant can only travel inside its containing block, so + // the clamped row made `c_euv_doc_toc` unpin after roughly one + // viewport of scroll. Never shrink: the row must carry the article's + // full height so the sticky range spans the whole page. + flex-shrink: "0"; } pub c_euv_doc_content { @@ -1078,15 +1086,13 @@ class! { flex-shrink: "0"; // Pin the TOC column to the viewport while the main content scrolls. // The sticky scroll container is `c_app_main` (the overflow:auto - // wrapper), so `top: 0` keeps the TOC aligned with the top of the - // scrollable area for the whole article. The column is left at its - // natural flex stretch height (= the article's content height) so - // the sticky range covers the whole main column — without the stretch - // the column would shrink to its content height and the sticky would - // disengage as soon as the TOC's bottom exits the viewport. + // wrapper), so this offset is measured from that container's top edge. + // It tracks `padding-main-top` rather than a bare `0` so the pinned + // column keeps the same breathing room below the container edge as the + // article has below the same edge — the two columns stay on one + // vertical rhythm instead of the TOC riding flush against the top. position: "sticky"; - top: "0px"; - padding-top: var!(padding-main-top); + top: var!(padding-main-top); @media ((max-width: 1100px)) { display: "none"; } diff --git a/ui/src/style/class/shell/fn.rs b/ui/src/style/class/shell/fn.rs index db5e9321..6219c1ce 100644 --- a/ui/src/style/class/shell/fn.rs +++ b/ui/src/style/class/shell/fn.rs @@ -584,7 +584,7 @@ class! { flex: "1"; height: "100%"; overflow: "auto"; - padding: format!("{} {} {} {}", var!(padding-main-top), var!(edge-gutter), var!(padding-main-bottom), var!(edge-gutter)); + padding: format!("{} {} 0 {}", var!(padding-main-top), var!(edge-gutter), var!(edge-gutter)); scrollbar-color: format!("{} {}", var!(scrollbar-thumb), var!(scrollbar-track)); ::-webkit-scrollbar { width: "6px"; @@ -601,7 +601,7 @@ class! { background: var!(scrollbar-thumb-active); } @media ((max-width: 767px)) { - padding: format!("{} {} {} {}", var!(padding-main-top-mobile), var!(edge-gutter-mobile), var!(padding-main-bottom), var!(edge-gutter-mobile)); + padding: format!("{} {} 0 {}", var!(padding-main-top-mobile), var!(edge-gutter-mobile), var!(edge-gutter-mobile)); scrollbar-width: "none"; ::-webkit-scrollbar { width: "0px"; @@ -994,11 +994,12 @@ class! { font-weight: "600"; // Same flush fill as `c_euv_sidebar_link_active_flush`: a nested // active group title should read as one block with the sidebar, not - // as a chip sitting 17px in from the edge. Keep the left padding so - // the label does not jump out from under the cursor, and pay for the - // shift with a matching negative margin. + // as a chip sitting in from the edge. The negative margin below pulls + // the fill out to this level's dashed guide; the left padding pays it + // straight back so the label keeps the exact x it has while inactive. margin-left: format!("-{}px", var!(side-indent-num)); width: format!("calc(100% + {}px)", var!(side-indent-num)); + padding-left: format!("calc({} + {}px)", var!(space-md), var!(side-indent-num)); :hover { box-shadow: "none"; } @@ -1077,17 +1078,25 @@ class! { // An active child needs the accent fill to read as one continuous block // against the sidebar, not as a chip floating inside its parent's gutter. // `c_euv_sidebar_children` insets every nesting level by - // `margin-left + padding-left` and paints a dashed guide on the way in, so - // a nested active link that keeps its own background starts 17px to the - // right of the row above it. This variant pulls the active fill back out - // to the sidebar's own left edge while leaving non-active rows indented, - // so the highlight lines up with the header and the section label. - // The negative margin is safe because the row is the full width of its - // container: the fill grows leftward into the parent's padding and - // nothing to its left moves. + // `margin-left + padding-left` and paints a dashed guide on the way in. + // The active row must pull its fill back out only as far as the guide it + // hangs from — `margin-left + border-left-width`, i.e. `side-indent` + // (9px) — so the fill starts exactly at the dashed rail and nothing + // crosses it. Compensating by the *full* inset (margin + padding + + // border = 17px) is wrong: it also cancels the row's own `padding-left`, + // pushing the fill 8px past the guide and past the sidebar's left edge on + // shallower levels. The negative margin is otherwise safe because the row + // is the full width of its container: the fill grows leftward into the + // parent's padding and nothing to its left moves. pub c_euv_sidebar_link_active_flush { display: "block"; + // Text must not move between the inactive and active states, so the + // negative margin that pulls the fill out to the dashed guide is paid + // back here as extra left padding. The two cancel for the label while + // the background still reaches the guide. Only `padding-left` is + // touched — the shorthand would drop the other three sides. padding: format!("{} {}", var!(space-md), var!(space-md)); + padding-left: format!("calc({} + {}px)", var!(space-md), var!(side-indent-num)); // `var!()` expands to a bare `var(--token)`, so the unit has to live // inside the token — writing `-{}px` here would emit // `calc(-var(--side-indent-num)px)`, which is not a length and gets @@ -1135,13 +1144,35 @@ class! { color: var!(muted-foreground); cursor: "pointer"; line-height: "1.5"; + // Regular weight by default: only the *active* entry carries weight, + // so hovering an inactive entry must not bold it or the weight would + // read as a false "you are here" cue on mouse-over alone. + font-weight: "400"; :hover { color: var!(accent); } } + pub c_euv_toc_link_active { + c_euv_toc_link(); + color: var!(accent); + font-weight: "700"; + :hover { + font-weight: "700"; + } + } + pub c_euv_toc_link_nested { c_euv_toc_link(); padding-left: var!(space-lg); } + + pub c_euv_toc_link_nested_active { + c_euv_toc_link_nested(); + color: var!(accent); + font-weight: "700"; + :hover { + font-weight: "700"; + } + } } diff --git a/ui/src/style/css/const.rs b/ui/src/style/css/const.rs index 7143b80c..61654c1b 100644 --- a/ui/src/style/css/const.rs +++ b/ui/src/style/css/const.rs @@ -13,8 +13,13 @@ pub(crate) const EUV_MD_CSS: &str = r#" position: relative; font-weight: 700; letter-spacing: -0.01em; - margin-top: 1.8em; - margin-bottom: 0.6em; + /* Vertical rhythm is token-driven so article copy sits on the same + scale as every other surface in the framework (`space-sm` between + text, `gap-component` before a block). em-based margins drift as + heading font sizes change, which is what made the docs article + spacing read wider than the rest of the UI. */ + margin-top: var(--gap-component); + margin-bottom: var(--space-sm); scroll-margin-top: 72px; line-height: 1.3; } @@ -41,10 +46,9 @@ pub(crate) const EUV_MD_CSS: &str = r#" font-weight: 400; transition: opacity 0.15s ease-out; user-select: none; - /* The `.md-body a` rule below applies `text-decoration: underline - dashed` to every anchor. The header-anchor is a typographic - icon, not a navigable link in the visual sense, so drop the - underline. */ + /* The `.md-body a` rule below applies `text-decoration: underline` to + every anchor. The header-anchor is a typographic icon, not a + navigable link in the visual sense, so drop the underline. */ text-decoration: none; } .md-body h1:hover .header-anchor, @@ -129,7 +133,9 @@ pub(crate) const EUV_MD_CSS: &str = r#" } } .md-body p, .md-body ul, .md-body ol, .md-body blockquote, .md-body pre, .md-body table { - margin: 1em 0; + /* Token rhythm: `space-sm` between blocks of copy, matching + `c_page_title` / `c_page_subtitle` in the example app. */ + margin: var(--space-sm) 0; } .md-body ul, .md-body ol { padding-left: 1.4em; @@ -137,9 +143,9 @@ pub(crate) const EUV_MD_CSS: &str = r#" .md-body ul { list-style: disc; } .md-body ol { list-style: decimal; } .md-body ul ul, .md-body ul ol, .md-body ol ul, .md-body ol ol { - margin: 0.25em 0; + margin: var(--space-xs) 0; } -.md-body li { margin: 0.25em 0; } +.md-body li { margin: var(--space-xs) 0; } .md-body li input[type="checkbox"] { margin-right: 0.4em; accent-color: var(--accent); @@ -147,14 +153,14 @@ pub(crate) const EUV_MD_CSS: &str = r#" .md-body a { color: var(--accent); font-weight: 500; + /* Static underline, no hover variant. A link that restyles itself on + hover reads as a state change rather than a link, and the dashed -> + solid swap made every hovered link shimmer. Keep one treatment for + both states; the accent colour alone carries the affordance. */ text-decoration: underline; text-underline-offset: 3px; - text-decoration-style: dashed; - text-decoration-color: var(--border); -} -.md-body a:hover { text-decoration-style: solid; - text-decoration-color: var(--accent); + text-decoration-color: var(--border); } .md-body strong { font-weight: 700; } .md-body em { font-style: italic; } diff --git a/ui/src/style/var/fn.rs b/ui/src/style/var/fn.rs index 47b93891..b9128fc0 100644 --- a/ui/src/style/var/fn.rs +++ b/ui/src/style/var/fn.rs @@ -51,12 +51,21 @@ vars! { // Sidebar nesting indent, in two forms. `side-indent` carries its own // `px` so it can be dropped into a plain `var()` position; // `side-indent-num` is the same number without a unit, for the places - // that have to do arithmetic (`calc(100% + 17px)`, `-17px`) where + // that have to do arithmetic (`calc(100% + 9px)`, `-9px`) where // appending `px` to a var would produce invalid CSS. Keep the two in - // step: `side-indent` is `space-sm * 2 + 1px` — the - // `c_euv_sidebar_children` margin, its padding, and its dashed border. - side-indent: "17px"; - side-indent-num: "17"; + // step: `side-indent` is `space-sm + 1px` — the + // `c_euv_sidebar_children` margin plus its dashed border. + // + // This value must NOT be the full nesting inset. It is only the + // distance from a row's own left edge back to the dashed guide that + // introduced it, which is `margin-left + border-left-width` + // (8 + 1 = 9px). The row's own `padding-left` is *not* part of it: + // an active row keeps its padding so the label does not move, and the + // negative margin grows the fill leftward into that padding. Using + // the full inset (17px) pushed the active fill 8px past the dashed + // guide, breaking the tree's left rail. + side-indent: "9px"; + side-indent-num: "9"; // ═══════════════════════════════════════════════════════════════════════ // Font Size Scale (shadcn/ui) @@ -102,7 +111,6 @@ vars! { padding-shell-bottom: var!(safe-area-inset-bottom); padding-main-top: "24px"; padding-main-top-mobile: "16px"; - padding-main-bottom: "24px"; // Edge Gutter — distance from a viewport/shell edge to the chrome // anchored there. Every edge-anchored element (mobile header left and // right, drawer header, desktop navbar, main column, vconsole FAB) @@ -226,12 +234,21 @@ vars! { // Sidebar nesting indent, in two forms. `side-indent` carries its own // `px` so it can be dropped into a plain `var()` position; // `side-indent-num` is the same number without a unit, for the places - // that have to do arithmetic (`calc(100% + 17px)`, `-17px`) where + // that have to do arithmetic (`calc(100% + 9px)`, `-9px`) where // appending `px` to a var would produce invalid CSS. Keep the two in - // step: `side-indent` is `space-sm * 2 + 1px` — the - // `c_euv_sidebar_children` margin, its padding, and its dashed border. - side-indent: "17px"; - side-indent-num: "17"; + // step: `side-indent` is `space-sm + 1px` — the + // `c_euv_sidebar_children` margin plus its dashed border. + // + // This value must NOT be the full nesting inset. It is only the + // distance from a row's own left edge back to the dashed guide that + // introduced it, which is `margin-left + border-left-width` + // (8 + 1 = 9px). The row's own `padding-left` is *not* part of it: + // an active row keeps its padding so the label does not move, and the + // negative margin grows the fill leftward into that padding. Using + // the full inset (17px) pushed the active fill 8px past the dashed + // guide, breaking the tree's left rail. + side-indent: "9px"; + side-indent-num: "9"; // ═══════════════════════════════════════════════════════════════════════ // Font Size Scale (same as light) @@ -277,7 +294,6 @@ vars! { padding-shell-bottom: var!(safe-area-inset-bottom); padding-main-top: "24px"; padding-main-top-mobile: "16px"; - padding-main-bottom: "24px"; // Edge Gutter — distance from a viewport/shell edge to the chrome // anchored there. Every edge-anchored element (mobile header left and // right, drawer header, desktop navbar, main column, vconsole FAB)