Selaa lähdekoodia

Neo: wiki layout polish and markdown spacing (#5854)

* Neo: wiki header buttons and TOC rail highlight

- Wiki menu trigger on small screens is a circle, not a capsule.
- "On this page" trigger in the mobile header is an icon-only circle below
  `sm` and shows its label from `sm` up.
- The active TOC rail no longer shrinks: the highlight SVG sat in a flex
  column clip box and was squeezed to the clip height, so it drew a scaled
  copy of the rail. flexShrink 0 keeps it at full height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: wiki mobile nav row uses px-4 to line up with the main header

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: wiki "On this page" trigger uses the regular size, matching the wiki menu button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: compact wiki breadcrumb matches the header button label type

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: wiki breadcrumb shows parents only, led by the docs root link

The current page's title is the article heading, so the breadcrumb no
longer repeats it. Top-level pages and the docs home show just the root
crumb, linking to the docs home.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: desktop wiki breadcrumb matches the TOC trigger type; tighter gap to the article

The breadcrumb row and the article block's top padding stacked with the
column gap (48px). The row now pulls the article up to 24px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: wiki article lines up with the breadcrumb

From sm the wiki center column pads itself, so the article block keeps its
designbox padding on mobile only. The breadcrumb cancels its first link's
inset, and the row pull is re-tuned to keep 24px above the title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: cap the wiki "On this page" menu at 320px so long headings wrap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: paragraph-sized gap under the wiki document header

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: wiki sidebar uses the conductor sidebar menu item

The wiki menu had its own item (large size, bare icon, accent text). It now
renders menu-item/sidebar_with_wrapper like the conductor sidebar: regular
size, round icon chip, same spacing, and the conductor's depth indent, set
on the wrapper so the button's inline contentInsets padding can't cancel it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: sidebar menu indents nested items again

The depth class (menu_categ_indent, pl-12, pl-16) went to the menu item's
button, where the inline contentInsets padding overrides it, so nested items
in conductor and sidebar menu blocks didn't indent. It now sits on a wrapper
around the item alone (not its children, so depths don't add up).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: markdown keeps paragraph spacing around block images

Images split the markdown into segments, and the last-child rule zeroed the
bottom margin of the paragraph before each image, so text touched the image.
Segments followed by another segment are now exempt (markdown-followed), and
block images get paragraph margins (16px) instead of a bottom-only 16px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: markdown margins collapse as designed (16px paragraph gap)

Paragraph margins (16px each side) were meant to collapse, but the renderer
lays markdown out in flex columns, so they added up to 32px. On web the
markdown root, its text segments and .enrm-text now flow as blocks, with
.neo-v's align-content reset (non-normal align-content makes a block its own
formatting context and stops collapsing across segments). Heading margins
from utilities.css now win over the renderer's inline 8px, at :where()
specificity so the first/last-child rules still zero the outer edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: address review on wiki layout PR

- Markdown images: the 16px top margin is web-only. Native doesn't collapse
  margins or zero the outer edges, so it keeps the bottom margin only.
- Breadcrumb row pull covers the 2xl left-nav breakpoint.
- Wiki menu press handler without a nested ternary (Sonar S3358).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 1 viikko sitten
vanhempi
sitoutus
b1528cbeec

+ 3 - 1
neo/packages/app/components/elements/menu.js

@@ -97,6 +97,8 @@ export default function ElementMenu({ data, blockWrapperProps, url }) {
 
             return (
                 <View key={`lmenu-${itemId}`} className="w-full">
+                    {/* Indent on a wrapper: on the button it loses to the inline contentInsets padding. */}
+                    <View className={getDepthClassName(depth)}>
                     <MenuItemSidebarWithWrapper
                         link={canNavigate ? itemPath : undefined}
                         title={title}
@@ -104,8 +106,8 @@ export default function ElementMenu({ data, blockWrapperProps, url }) {
                         iconEnd={!canNavigate && hasChildren ? (isExpanded ? 'ChevronDown' : 'ChevronRight') : undefined}
                         isActive={isActive}
                         onPress={!canNavigate && hasChildren ? () => toggleExpanded(itemId) : undefined}
-                        className={getDepthClassName(depth)}
                     />
+                    </View>
                     {hasChildren && isExpanded ? (
                         <View className="gap-0.5">
                             {renderItems(children, depth + 1, indexPath)}

+ 4 - 2
neo/packages/app/components/elements/wiki/document.js

@@ -227,7 +227,7 @@ export function WikiDocumentHeader({ markdownSource = '', metadata, pageUrl = ''
     })
 
     return (
-        <View className="w-full border-b border-border/60 gap-3 pb-2">
+        <View className="w-full border-b border-border/60 gap-3 pb-2 mb-4">
             <Row className="items-center gap-4">
                 {resolvedIconUrl ? (
                     <WikiDocumentIcon
@@ -332,8 +332,10 @@ export function WikiArticleBlock({
     const { menu, added, attributes, body, raw } = getWikiBlockPayload(block);
     const hasDocHeader = Boolean(attributes?.title);
 
+    // Padding on mobile only: from `sm` the wiki center column pads itself,
+    // and the block's px would push the article off the breadcrumb line.
     return (
-        <BlockWrapper block={block} config={block?.config_api} showTitle={!hasDocHeader}>
+        <BlockWrapper block={block} config={block?.config_api} showTitle={!hasDocHeader} showPadding={['mobile']}>
             {hasDocHeader ? (
                 <WikiDocumentHeader
                     markdownSource={raw}

+ 12 - 18
neo/packages/app/components/elements/wiki/helpers.js

@@ -77,9 +77,13 @@ export function findWikiBreadcrumbTrail(items, currentPathComparable, ancestors
     return null
 }
 
+// Parents only: the current page's title is already the article heading.
+// The root crumb ("Docs") always leads, as a link to the docs home, so
+// top-level pages and the home itself show just the root.
 export function buildWikiBreadcrumbs(menuItems, currentPath) {
     const currentKey = normalizePathComparable(currentPath)
     const trail = findWikiBreadcrumbTrail(menuItems, currentKey) || []
+    const parents = trail.filter((crumb) => !crumb.isCurrent)
     const root = appSetting('wiki', 'breadcrumb') || {}
     const rootLabel = String(root.root_label || '').trim()
     const rootPath = root.root_path
@@ -87,28 +91,18 @@ export function buildWikiBreadcrumbs(menuItems, currentPath) {
         : null
     const rootKey = normalizePathComparable(rootPath)
 
-    if (!rootLabel) return trail
+    if (!rootLabel) return parents
 
-    const rootCrumb = {
+    if (parents[0] && rootKey && normalizePathComparable(parents[0].path) === rootKey) {
+        return parents
+    }
+
+    return [{
         key: 'wiki-breadcrumb-root',
         title: rootLabel,
         path: rootPath,
-        isCurrent: Boolean(rootKey && rootKey === currentKey),
-    }
-
-    if (trail[0] && normalizePathComparable(trail[0].path) === rootKey) {
-        return trail
-    }
-
-    if (rootCrumb.isCurrent && !trail.length) {
-        return [rootCrumb]
-    }
-
-    if (rootCrumb.isCurrent) {
-        return trail
-    }
-
-    return [rootCrumb, ...trail]
+        isCurrent: false,
+    }, ...parents]
 }
 
 export const stripMarkdownInline = (text) => String(text || '')

+ 38 - 99
neo/packages/app/components/elements/wiki/nav.js

@@ -2,12 +2,10 @@ import { useEffect, useState } from 'react'
 import { View, Row, Pressable } from 'app/design/view'
 import { Text } from 'app/design/typography'
 import { Icon } from 'app/ui/atoms/icon'
-import Image from 'app/ui/atoms/image'
-import { NeoButton, NeoButtonLink } from 'app/design/controls'
-import { isEmoji } from 'app/lib/util'
+import { components } from 'app/components/registry'
+import { getConductorDepthClassName } from 'app/ui/molecules/sections/conductor/helpers'
 import { useGlobalSearchParams, usePathname } from 'app/lib/hooks/router'
 import {
-  getDepthClassName,
   getItemId,
   getItemPath,
   getRouteParam,
@@ -20,9 +18,15 @@ export function WikiBreadcrumb({ items, onNavigate, compact = false }) {
     const { t } = useTranslation()
     if (!items?.length) return null
 
+    // Sits next to the "On this page" trigger: match its label type
+    // (neo_button regular + borderless) so the row reads as one line.
+    const textCls = 'text-base leading-6 font-semibold tracking-tight'
+    const currentCls = 'text-card-foreground'
+
     return (
         <Row
-            className={`items-center flex-wrap min-w-0 ${compact ? 'gap-1 flex-1' : 'gap-1.5'}`}
+            // -ms-1 cancels the first link's px-1 so its text lines up with the article.
+            className={`items-center flex-wrap min-w-0 ${compact ? 'gap-1 flex-1' : 'gap-1.5 -ms-1'}`}
             role="navigation"
             accessibilityLabel={t('Breadcrumb')}
         >
@@ -38,10 +42,8 @@ export function WikiBreadcrumb({ items, onNavigate, compact = false }) {
                     {item.isCurrent || !item.path || !onNavigate ? (
                         <Text
                             numberOfLines={1}
-                            className={`text-sm leading-5 ${
-                                item.isCurrent
-                                    ? 'text-foreground font-medium'
-                                    : 'text-muted-foreground'
+                            className={`${textCls} ${
+                                item.isCurrent ? currentCls : 'text-muted-foreground'
                             }`}
                         >
                             {item.title}
@@ -56,7 +58,7 @@ export function WikiBreadcrumb({ items, onNavigate, compact = false }) {
                         >
                             <Text
                                 numberOfLines={1}
-                                className="text-sm leading-5 text-muted-foreground web:hover:text-foreground"
+                                className={`${textCls} text-muted-foreground web:hover:text-foreground`}
                             >
                                 {item.title}
                             </Text>
@@ -110,48 +112,6 @@ export function isImageSource(icon) {
     return false;
 }
 
-export function WikiMenuItem({ title, icon, isActive, iconEnd }) {
-    const iconClassName = isActive
-        ? 'text-accent-foreground'
-        : 'text-secondary-foreground web:group-hover:text-foreground'
-    const iconBackgroundClassName = isActive
-        ? 'text-accent-foreground'
-        : 'text-secondary-foreground'
-
-    return (
-        <Row className="w-full items-center gap-2">
-            <View className={`h-8 w-8 shrink-0 items-center justify-center  ${iconBackgroundClassName}`}>
-                {isImageSource(icon) ? (
-                    <Image
-                        src={icon}
-                        className="h-6 w-6 rounded-md"
-                        view="cover"
-                        sizes="auto"
-                    />
-                ) : isEmoji(icon) ? (
-                    <Text className="text-xs leading-none">{icon}</Text>
-                ) : (
-                    <Icon icon={icon} size={20} className={iconClassName} />
-                )}
-            </View>
-
-            <Text className={`flex-1 text-sm leading-5 font-semibold ${isActive ? 'text-accent-foreground' : 'text-secondary-foreground web:group-hover:text-foreground'}`}>
-                {title}
-            </Text>
-
-            {!!iconEnd && (
-                <View className="ml-auto h-6 w-6 shrink-0 items-center justify-center rounded-full">
-                    {isEmoji(iconEnd) ? (
-                        <Text className="text-xs leading-none text-secondary-foreground">{iconEnd}</Text>
-                    ) : (
-                        <Icon icon={iconEnd} size={15} className="text-secondary-foreground web:group-hover:text-foreground" />
-                    )}
-                </View>
-            )}
-        </Row>
-    )
-}
-
 export function ActiveBranchExpander({ currentPathComparable, items, setExpandedMap }) {
     useEffect(() => {
         const activeExpandedMap = buildExpandedMapForPath(items, currentPathComparable);
@@ -185,6 +145,7 @@ export function MenuWiki({ onNavigate, block, url }) {
     const currentPathComparable = normalizePathComparable(url) || routePathComparable;
     const initialPathComparable = normalizePathComparable(url);
     const topLevelItems = data?.content?.items || [];
+    const MenuItemSidebarWithWrapper = components['menu-item']['sidebar_with_wrapper'];
     const [expandedMap, setExpandedMap] = useState(() => buildExpandedMapForPath(topLevelItems, initialPathComparable));
 
     const toggleExpanded = (id) => {
@@ -199,7 +160,6 @@ export function MenuWiki({ onNavigate, block, url }) {
             const children = item?.subitems || [];
             const hasChildren = children.length > 0;
             const isExpanded = Boolean(expandedMap[itemId]);
-            const depthClassName = getDepthClassName(depth);
             const title = item?.title || item?.name;
             const icon = item?.icon || item?.image || 'Circle';
             const canNavigate = hasItemPath(item);
@@ -209,53 +169,32 @@ export function MenuWiki({ onNavigate, block, url }) {
             // Parent-only nodes (no path) toggle expand/collapse; leaves navigate.
             const showChevron = !canNavigate && hasChildren;
             const menuIconEnd = showChevron ? (isExpanded ? 'ChevronDown' : 'ChevronRight') : null;
-            const menuItem = (
-                <WikiMenuItem
-                    title={title}
-                    icon={icon}
-                    isActive={menuIsActive}
-                    iconEnd={menuIconEnd}
-                />
-            );
-
+            let handlePress;
+            if (canNavigate) {
+                handlePress = (event) => {
+                    onNavigate?.(itemPath)
+                    event?.preventDefault?.()
+                }
+            } else if (hasChildren) {
+                handlePress = () => toggleExpanded(itemId)
+            }
             return (
-                <View key={`lmenu-${itemId}`} className={`w-full ${depthClassName}`}>
-                    {canNavigate ? (
-                        <NeoButtonLink
-                            href={itemPath}
-                            alt={title}
-                            style="borderless"
-                            controlSize="large"
-                            width="fill"
-                            align="start"
-                            contentInsets={{ x: 8 }}
-                            selected={menuIsActive}
-                            selectedState="pressed"
-                            className="group"
-                            onPress={(event) => {
-                                onNavigate?.(itemPath)
-                                event?.preventDefault?.()
-                            }}
-                        >
-                            {menuItem}
-                        </NeoButtonLink>
-                    ) : (
-                        <NeoButton
-                            alt={title}
-                            style="borderless"
-                            controlSize="large"
-                            width="fill"
-                            align="start"
-                            contentInsets={{ x: 8 }}
-                            interactive
-                            className="group"
-                            onPress={hasChildren ? () => toggleExpanded(itemId) : undefined}
-                        >
-                            {menuItem}
-                        </NeoButton>
-                    )}
+                <View key={`lmenu-${itemId}`} className="w-full">
+                    {/* Same item as the conductor sidebar (menu-item/sidebar_with_wrapper),
+                        so wiki and section menus look alike and forks style both at once.
+                        Indent on a wrapper: on the button it loses to the inline contentInsets padding. */}
+                    <View className={getConductorDepthClassName(depth)}>
+                    <MenuItemSidebarWithWrapper
+                        link={canNavigate ? itemPath : undefined}
+                        title={title}
+                        icon={icon}
+                        iconEnd={menuIconEnd}
+                        isActive={menuIsActive}
+                        onPress={handlePress}
+                    />
+                    </View>
                     {hasChildren && isExpanded && (
-                        <View className="mt-1 gap-1">
+                        <View className="gap-0.5">
                             {renderItems(children, depth + 1, indexPath)}
                         </View>
                     )}
@@ -270,7 +209,7 @@ export function MenuWiki({ onNavigate, block, url }) {
                 items={topLevelItems}
                 setExpandedMap={setExpandedMap}
             />
-            <View className=' gap-1 lg:-mx-2'>
+            <View className='gap-0.5 lg:-mx-2'>
                 {renderItems(topLevelItems)}
             </View>
         </>

+ 27 - 7
neo/packages/app/components/elements/wiki/toc.js

@@ -350,7 +350,9 @@ function WikiTocTrack({ geometry, activeIds }) {
                         width={geometry.width}
                         height={geometry.height}
                         viewBox={`0 0 ${geometry.width} ${geometry.height}`}
-                        style={{ marginTop: -trackTop }}
+                        // The clip box is a flex column: without flexShrink 0 the SVG
+                        // shrinks to the clip height and the rail draws scaled down.
+                        style={{ marginTop: -trackTop, flexShrink: 0 }}
                     >
                         <Path
                             d={geometry.d}
@@ -597,19 +599,26 @@ function WikiTocMenuList({ items, activeIds = [], focusedId = null, onPress }) {
     )
 }
 
-export function WikiTocDropdown({ items, activeIds, focusedId = null, onPress, label }) {
+/**
+ * @param {object} props
+ * @param {boolean} [props.compact] — icon-only circle on mobile, label from `sm` up
+ */
+export function WikiTocDropdown({ items, activeIds, focusedId = null, onPress, label, compact = false }) {
     if (!items?.length || items.length < 2) return null
 
-    return (
+    const renderDropdown = (iconOnly) => (
         <DropdownPopup
             minPopupWidth={256}
+            // Long headings wrap instead of stretching the menu across the page.
+            maxPopupWidth={320}
             contentClasses={isWeb ? 'rounded-xl border border-popover mt-2 bg-popover/60 backdrop-blur shadow-md' : undefined}
             buttonProps={{
-                label,
+                label: iconOnly ? undefined : label,
+                accessibilityLabel: label,
                 style: 'borderless',
-                controlSize: 'small',
+                controlSize: 'regular',
                 image: 'TextAlignStart',
-                borderShape: 'capsule',
+                borderShape: iconOnly ? 'circle' : 'capsule',
             }}
         >
             <View className="p-1">
@@ -622,4 +631,15 @@ export function WikiTocDropdown({ items, activeIds, focusedId = null, onPress, l
             </View>
         </DropdownPopup>
     )
-}
+
+    if (!compact) return renderDropdown(false)
+
+    // NeoButton `showTitleFromSize` keeps the capsule padding when the label is
+    // hidden, so swap two triggers by CSS instead (SSR-safe, no measuring).
+    return (
+        <>
+            <View className="sm:hidden">{renderDropdown(true)}</View>
+            <View className="hidden sm:flex">{renderDropdown(false)}</View>
+        </>
+    )
+}

+ 17 - 5
neo/packages/app/components/page-layout/wiki.js

@@ -36,6 +36,17 @@ import { WikiArticleBlock } from 'app/components/elements/wiki/document'
 import { MenuWiki, WikiBreadcrumb } from 'app/components/elements/wiki/nav'
 import { useWikiNavigation } from 'app/components/elements/wiki/use-navigation'
 
+// Pulls the article up under the breadcrumb row so the column gap doesn't
+// leave a wide space (row bottom to article title: 24px). Static literals for
+// Tailwind scanning; keyed by the left-nav breakpoint the row shows from.
+const BREADCRUMB_ROW_PULL = {
+    sm: 'sm:-mb-2',
+    md: 'md:-mb-2',
+    lg: 'lg:-mb-2',
+    xl: 'xl:-mb-2',
+    '2xl': '2xl:-mb-2',
+}
+
 /**
  * Wiki page layout — 3-column docs chrome (nav / article / TOC).
  *
@@ -268,7 +279,7 @@ function PageContentWiki({ data, scrollRef, url }) {
         }
 
         return (
-            <View className="w-full py-2 px-3 sm:px-4 ">
+            <View className="w-full py-2 px-4">
                 <View className="flex-row flex-auto items-center flex-wrap gap-2">
                     <View className={`flex-row items-center gap-1.5 min-w-0 ${showBothMobilePanels ? 'flex-auto' : 'flex-1'}`}>
                         <View className="shrink-0">
@@ -279,7 +290,7 @@ function PageContentWiki({ data, scrollRef, url }) {
                                     style: 'borderless',
                                     controlSize: 'regular',
                                     image: 'Menu',
-                                    borderShape: 'capsule',
+                                    borderShape: 'circle',
                                 }}
                             >
                                 <View className="p-1">
@@ -305,6 +316,7 @@ function PageContentWiki({ data, scrollRef, url }) {
                                 focusedId={focusedTocId}
                                 onPress={handleTocPress}
                                 label={tocMenuLabel}
+                                compact
                             />
                         </View>
                     ) : null}
@@ -375,12 +387,12 @@ function PageContentWiki({ data, scrollRef, url }) {
                     <PanelHandler gap={`hidden ${leftBreakpoint}:block`} sizable={cellsCustomConfig.sizable} panelLine={cellsCustomConfig['panel-line']} />
                     <Panel id="wiki-center" order={2} className={`native:w-full   ${currentBreakpointName}:w-full`} {...centerPanelLayoutProps}>
                         <View ref={centerContentRef} className="sm:p-4 lg:px-6 lg:py-6 xl:px-8 2xl:px-12 gap-6 lg:gap-8">
-                            {(breadcrumbs.length >1 || showTocMenu) ? (
-                                <View className={`gap-3  hidden ${leftBreakpoint}:flex`}>
+                            {(breadcrumbs.length > 0 || showTocMenu) ? (
+                                <View className={`gap-3 hidden ${leftBreakpoint}:flex ${BREADCRUMB_ROW_PULL[leftBreakpoint] ?? ''}`}>
                                     {/* CSS visibility only — do not gate on useBreakpoint (SSR mismatch).
                                         Breadcrumb from left-nav bp; TOC trigger until right sidebar bp. */}
                                     <View className={`hidden ${leftBreakpoint}:flex flex-row items-center justify-between gap-2 min-w-0`}>
-                                        {breadcrumbs.length >1 ? (
+                                        {breadcrumbs.length > 0 ? (
                                             <View className="min-w-0 flex-1">
                                                 <WikiBreadcrumb
                                                     items={breadcrumbs}

+ 63 - 14
neo/packages/app/design/styles/utilities.css

@@ -194,9 +194,13 @@
         margin-top: 0 !important;
     }
 
+    /* `.markdown-followed`: a text segment with an image / code segment after
+     * it keeps its last margin, so the gap matches paragraph spacing. */
     .u-vanilla-html
         div
-        > :where(:last-child):not(:where([class~='not-prose'] *)),
+        > :where(:last-child):not(
+            :where([class~='not-prose'] *, .markdown-followed *)
+        ),
     .u-vanilla-html > :where(:last-child):not(:where([class~='not-prose'] *)) {
         margin-bottom: 0 !important;
     }
@@ -213,7 +217,9 @@
 
     .u-vanilla-html-small
         div
-        > :where(:last-child):not(:where([class~='not-prose'] *)),
+        > :where(:last-child):not(
+            :where([class~='not-prose'] *, .markdown-followed *)
+        ),
     .u-vanilla-html-small
         > :where(:last-child):not(:where([class~='not-prose'] *)) {
         margin-bottom: 0 !important;
@@ -251,8 +257,62 @@
             sans-serif;
     }
 
+    /* Heading margins. `!important` beats the renderer's inline 8px; `:where()`
+     * keeps specificity low so the first/last-child rules still zero the outer edges. */
+    :where(.u-vanilla-html) h1 {
+        margin-block: 8px 24px !important;
+    }
+    :where(.u-vanilla-html) h2 {
+        margin-block: 24px 16px !important;
+    }
+    :where(.u-vanilla-html) h3 {
+        margin-block: 16px 8px !important;
+    }
+    :where(.u-vanilla-html) h4 {
+        margin-block: 8px 4px !important;
+    }
+    :where(.u-vanilla-html) h5 {
+        margin-block: 8px 4px !important;
+    }
+    :where(.u-vanilla-html) h6 {
+        margin-block: 8px 4px !important;
+    }
+    :where(.u-vanilla-html-small) h1 {
+        margin-block: 8px 20px !important;
+    }
+    :where(.u-vanilla-html-small) h2 {
+        margin-block: 20px 12px !important;
+    }
+    :where(.u-vanilla-html-small) h3 {
+        margin-block: 14px 6px !important;
+    }
+    :where(.u-vanilla-html-small) h4 {
+        margin-block: 8px 4px !important;
+    }
+    :where(.u-vanilla-html-small) h5 {
+        margin-block: 8px 4px !important;
+    }
+    :where(.u-vanilla-html-small) h6 {
+        margin-block: 8px 4px !important;
+    }
+
+    /* Markdown flows as blocks so paragraph / heading / image margins collapse into
+     * one gap (flex items never collapse: 16px + 16px became 32px). The renderer's
+     * `.enrm-text` is an inline flex column; segment wrappers are flex Views, and
+     * their `.neo-v` `align-content: flex-start` would make each one its own
+     * formatting context, which also stops margins collapsing across segments. */
+    .markdown-flow,
+    .markdown-flow > .markdown-text,
+    .markdown-flow .enrm-text {
+        display: block !important;
+        align-content: normal !important;
+    }
+
+    .markdown-flow > img {
+        display: block;
+    }
+
     .u-vanilla-html h1 {
-        margin-block: 8px 24px;
         font-size: 36px;
         font-weight: 700;
         line-height: 40px;
@@ -260,7 +320,6 @@
     }
 
     .u-vanilla-html h2 {
-        margin-block: 24px 16px;
         font-size: 24px;
         font-weight: 700;
         line-height: 32px;
@@ -268,7 +327,6 @@
     }
 
     .u-vanilla-html h3 {
-        margin-block: 16px 8px;
         font-size: 20px;
         font-weight: 700;
         line-height: 28px;
@@ -276,28 +334,24 @@
     }
 
     .u-vanilla-html h4 {
-        margin-block: 8px 4px;
         font-size: 18px;
         font-weight: 600;
         line-height: 28px;
     }
 
     .u-vanilla-html h5 {
-        margin-block: 8px 4px;
         font-size: 16px;
         font-weight: 600;
         line-height: 24px;
     }
 
     .u-vanilla-html h6 {
-        margin-block: 8px 4px;
         font-size: 14px;
         font-weight: 600;
         line-height: 20px;
     }
 
     .u-vanilla-html-small h1 {
-        margin-block: 8px 20px;
         font-size: 30px;
         font-weight: 700;
         line-height: 36px;
@@ -305,7 +359,6 @@
     }
 
     .u-vanilla-html-small h2 {
-        margin-block: 20px 12px;
         font-size: 22px;
         font-weight: 700;
         line-height: 28px;
@@ -313,28 +366,24 @@
     }
 
     .u-vanilla-html-small h3 {
-        margin-block: 14px 6px;
         font-size: 18px;
         font-weight: 700;
         line-height: 24px;
     }
 
     .u-vanilla-html-small h4 {
-        margin-block: 8px 4px;
         font-size: 16px;
         font-weight: 600;
         line-height: 22px;
     }
 
     .u-vanilla-html-small h5 {
-        margin-block: 8px 4px;
         font-size: 14px;
         font-weight: 600;
         line-height: 20px;
     }
 
     .u-vanilla-html-small h6 {
-        margin-block: 8px 4px;
         font-size: 12px;
         font-weight: 600;
         line-height: 18px;

+ 7 - 2
neo/packages/app/ui/atoms/markdown.tsx

@@ -199,7 +199,12 @@ function SizedMarkdownImage({ alt, height, src, width }: { alt?: string; height?
             style={{
                 borderRadius: 8,
                 height: resolvedHeight,
+                // Web: spaced like a paragraph (`.u-vanilla-html p` margins; they
+                // collapse with neighbours in `.markdown-flow`, and the first/last-child
+                // rules zero the outer edges). Native collapses nothing, so it keeps
+                // the bottom margin only.
                 marginBottom: 16,
+                marginTop: Platform.OS === 'web' ? 16 : 0,
                 maxWidth: '100%',
                 width: resolvedWidth,
             }}
@@ -252,7 +257,7 @@ function MarkdownSegments({
             return (
                 <View
                     key={`markdown-${index}`}
-                    className={`${index > 0 ? 'markdown-continuation' : ''} min-w-0 max-w-full`}
+                    className={`markdown-text ${index > 0 ? 'markdown-continuation' : ''} ${index < segments.length - 1 ? 'markdown-followed' : ''} min-w-0 max-w-full`}
                 >
                     <EnrichedMarkdownText
                         selectable
@@ -487,7 +492,7 @@ export default function ElementMarkdown({ data, customClassName, className = '',
     if (!data) return null
 
     return (
-        <View className={`max-w-full u-vanilla-html ${customClassName || ''} ${className}`.trim()} ref={innerRef}>
+        <View className={`max-w-full u-vanilla-html markdown-flow ${customClassName || ''} ${className}`.trim()} ref={innerRef}>
             <MarkdownSegments
                 alertMarkdownStyle={alertMarkdownStyle}
                 handleLinkPress={handleLinkPress}

+ 3 - 1
neo/packages/app/ui/molecules/sections/conductor/web-ui.js

@@ -811,6 +811,8 @@ export function SidebarNav({ routes, index, onSelect }) {
 
         return (
             <View key={`lmenu-${route.index}`} className="w-full">
+                {/* Indent on a wrapper: on the button it loses to the inline contentInsets padding. */}
+                <View className={getConductorDepthClassName(route.depth)}>
                 <MenuItemSidebarWithWrapper
                     link={canNavigate ? href : undefined}
                     title={route.title}
@@ -829,8 +831,8 @@ export function SidebarNav({ routes, index, onSelect }) {
                                 onSelect(route)
                             }
                     }
-                    className={getConductorDepthClassName(route.depth)}
                 />
+                </View>
                 {hasChildren && isExpanded ? (
                     <View className="gap-0.5">
                         {children.map((child) => renderRoute(child))}