diff --git a/README.md b/README.md index 2e8d605..a2e90aa 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ SharedDocsLib — небольшой Python-фреймворк для докум UI не содержит языко-зависимых служебных подписей. Базовый шрифт — `system-ui 16px`, а служебные UTF-8-пиктограммы используют Arial. Навигационные стрелки отрисовываются CSS-границами без текстовых символов. AMOLED-тема включена по умолчанию и сохраняется локально после переключения. -На десктопе компактный header содержит полное название документации, поиск и переключатель темы, а слева постоянно видны header-кнопки и дерево страниц. Поисковый placeholder медленно печатает первые слова случайного названия, добавляет троеточие только в конце и сохраняет готовый текст 5 секунд; при вводе поле раскрывает ранжированные результаты вниз. Проверка manifest выполняется автоматически в фоне без отдельной кнопки обновления. +На десктопе компактный header содержит toggle боковой панели, полное название документации, поиск и переключатель темы. Sidebar по умолчанию открыт, его состояние не сохраняется между загрузками. Поисковый placeholder медленно печатает первые слова случайного названия, добавляет троеточие только в конце и сохраняет готовый текст 5 секунд; при вводе поле раскрывает ранжированные результаты вниз. Проверка manifest выполняется автоматически в фоне без отдельной кнопки обновления. -На мобильных устройствах используется viewport `100dvh` и нижний dock. Кнопка `☰` заменяет содержимое страницы копией desktop-sidebar. Ввод в нижний поиск заменяет содержимое результатами, расположенными снизу вверх по релевантности. Тема и обновление на мобильном скрыты. +На мобильных устройствах используется viewport `100dvh` и нижний dock. Кнопка `☰` заменяет содержимое страницы копией desktop-sidebar, которая выезжает с левой границы экрана. Ввод в нижний поиск заменяет содержимое результатами, расположенными снизу вверх по релевантности. При переходе между страницами контент плавно смещается на 30 px по направлению навигации и меняет прозрачность; тема и обновление на мобильном скрыты. ## Установка из Git diff --git a/docslib/_version.py b/docslib/_version.py index 89acfb9..cb1c0c9 100644 --- a/docslib/_version.py +++ b/docslib/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the SharedDocsLib package version.""" -__version__ = "0.3.9" +__version__ = "0.4.0" diff --git a/docslib/templates/client.css b/docslib/templates/client.css index b5533ff..4efcb4b 100644 --- a/docslib/templates/client.css +++ b/docslib/templates/client.css @@ -46,13 +46,17 @@ button { color: inherit; } .app-shell { --desktop-sidebar-width: clamp(270px, 25vw, 400px); display: flex; width: 100%; height: 100%; flex-direction: column; overflow: hidden; background: var(--shell); } .desktop-header { position: relative; z-index: 20; display: grid; min-height: 76px; flex: 0 0 76px; grid-template-columns: minmax(260px, 1fr) auto; align-items: center; gap: 18px; padding: 12px 20px; background: var(--shell); } +.desktop-title-group { display: flex; min-width: 0; align-items: center; gap: 12px; } .desktop-title { overflow: hidden; margin: 0; font-size: 1.55rem; font-weight: 450; line-height: 1.1; letter-spacing: -.025em; text-overflow: ellipsis; white-space: nowrap; } +.desktop-sidebar-toggle { display: grid; width: 44px; height: 44px; flex: 0 0 44px; place-items: center; border-radius: 50%; color: var(--text); background: var(--control); font-size: 1.6rem; } +.desktop-sidebar-toggle:hover { background: var(--active); } .header-link { display: block; width: 100%; padding: 6px 12px; border: 0; border-radius: 20px; color: var(--text); background: transparent; text-decoration: none; line-height: 1.2; transition: background .16s ease; } .header-link:hover { background: var(--control); } .header-link.active { color: var(--text); background: var(--active); } .desktop-main { display: flex; min-width: 0; min-height: 0; flex: 1; overflow: hidden; } -.desktop-sidebar { display: flex; width: var(--desktop-sidebar-width); min-width: 0; height: 100%; flex: 0 0 var(--desktop-sidebar-width); flex-direction: column; overflow: hidden; padding: 0 18px; background: var(--shell); } +.desktop-sidebar { display: flex; width: var(--desktop-sidebar-width); min-width: 0; height: 100%; flex: 0 0 var(--desktop-sidebar-width); flex-direction: column; overflow: hidden; padding: 0 18px; background: var(--shell); opacity: 1; transition: width .24s ease, flex-basis .24s ease, padding .24s ease, opacity .16s ease; } +body.desktop-sidebar-collapsed .desktop-sidebar { width: 0; flex-basis: 0; padding-right: 0; padding-left: 0; opacity: 0; pointer-events: none; } .sidebar-header-buttons { display: flex; flex: 0 0 auto; flex-direction: column; gap: 4px; margin-bottom: 0; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); } .sidebar-header-buttons .header-link { color: var(--text); } .sidebar-header-buttons .header-link:hover { background: var(--control); } @@ -157,6 +161,7 @@ button { color: inherit; } body.mobile-menu-open .mobile-menu-view { display: block; } body.mobile-search-open .mobile-search-view { display: flex; } .mobile-menu-view { padding: 52px 28px calc(150px + env(safe-area-inset-bottom)); } + body.mobile-menu-open .mobile-menu-view { animation: mobile-menu-enter .26s cubic-bezier(.2, .8, .2, 1) both; } .mobile-site-title { margin: 0 0 34px; font-size: clamp(2.4rem, 11vw, 4rem); font-weight: 400; line-height: 1.1; letter-spacing: -.035em; overflow-wrap: anywhere; } .mobile-header-buttons { display: flex; flex-direction: column; gap: 6px; margin-bottom: 38px; border: 1px solid var(--border); border-radius: 24px; background: var(--surface); } .mobile-header-buttons .header-link { padding: 12px 18px; border-radius: 28px; font-size: 1.05rem; } @@ -177,6 +182,11 @@ button { color: inherit; } .mobile-search-control .search-glyph { width: 64px; height: 64px; flex-basis: 64px; background: transparent; font-size: 2.3rem; } } +@keyframes mobile-menu-enter { + from { opacity: 0; transform: translateX(-100%); } + to { opacity: 1; transform: translateX(0); } +} + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } } diff --git a/docslib/templates/client.js b/docslib/templates/client.js index dd962f2..14a3096 100644 --- a/docslib/templates/client.js +++ b/docslib/templates/client.js @@ -7,6 +7,7 @@ const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); const elements = { desktopTitle: document.getElementById("desktop-site-title"), + desktopSidebarToggle: document.getElementById("desktop-sidebar-toggle"), mobileTitle: document.getElementById("mobile-site-title"), desktopHeaderButtons: document.getElementById("desktop-header-buttons"), mobileHeaderButtons: document.getElementById("mobile-header-buttons"), @@ -44,6 +45,8 @@ let lastPlaceholderPage = -1; let customScriptLoaded = false; let lastSyncedRevision = String(manifest.revision || ""); + let pageTransitionId = 0; + let pageAnimation = null; const dbPromise = new Promise((resolve, reject) => { if (!window.indexedDB) return reject(new Error("IndexedDB")); @@ -135,6 +138,11 @@ document.body.classList.toggle("mobile-search-open", mode === "search"); } + function setDesktopSidebar(open) { + document.body.classList.toggle("desktop-sidebar-collapsed", !open); + elements.desktopSidebarToggle.setAttribute("aria-expanded", String(open)); + } + function closeSearch({ clear = false } = {}) { elements.desktopSearchShell.classList.remove("open"); if (clear) { @@ -146,11 +154,13 @@ function navigate(number, replace = false) { const hash = `#/page/${encodeURIComponent(number)}`; - if (replace) history.replaceState(null, "", hash); - else if (location.hash !== hash) location.hash = hash; closeSearch({ clear: true }); setMobileMode("content"); - renderPage(number); + if (replace) { + history.replaceState(null, "", hash); + renderPage(number); + } else if (location.hash !== hash) location.hash = hash; + else renderPage(number); } function buildTree(container) { @@ -342,15 +352,7 @@ button.onclick = () => navigate(page.number); } - function renderPage(number) { - if (!manifest.pages.length) { - elements.content.replaceChildren(); - elements.previous.disabled = elements.next.disabled = true; - return; - } - let index = manifest.pages.findIndex((page) => page.number === number); - if (index < 0) index = 0; - const page = manifest.pages[index]; + function applyPage(page, index) { currentNumber = page.number; if (routeNumber() !== page.number) history.replaceState(null, "", `#/page/${encodeURIComponent(page.number)}`); document.title = `${page.title} · ${manifest.title}`; @@ -369,6 +371,53 @@ elements.pageSurface.scrollTo({ top: 0, behavior: "instant" }); } + async function renderPage(number) { + if (!manifest.pages.length) { + elements.content.replaceChildren(); + elements.previous.disabled = elements.next.disabled = true; + return; + } + let index = manifest.pages.findIndex((page) => page.number === number); + if (index < 0) index = 0; + const page = manifest.pages[index]; + if (page.number === currentNumber && elements.content.childNodes.length) return; + + const currentIndex = manifest.pages.findIndex((candidate) => candidate.number === currentNumber); + const direction = currentIndex >= 0 && index < currentIndex ? -1 : 1; + const animate = currentIndex >= 0 && !reducedMotion.matches && typeof elements.content.animate === "function"; + const transitionId = ++pageTransitionId; + + pageAnimation?.cancel(); + pageAnimation = null; + if (animate) { + const outgoing = elements.content.animate( + [ + { opacity: 1, transform: "translateX(0)" }, + { opacity: 0, transform: `translateX(${-30 * direction}px)` }, + ], + { duration: 150, easing: "ease-in", fill: "forwards" }, + ); + pageAnimation = outgoing; + await outgoing.finished.catch(() => {}); + if (transitionId !== pageTransitionId) return; + outgoing.cancel(); + } + + applyPage(page, index); + if (!animate || transitionId !== pageTransitionId) return; + const incoming = elements.content.animate( + [ + { opacity: 0, transform: `translateX(${30 * direction}px)` }, + { opacity: 1, transform: "translateX(0)" }, + ], + { duration: 210, easing: "ease-out" }, + ); + pageAnimation = incoming; + incoming.finished.catch(() => {}).finally(() => { + if (pageAnimation === incoming) pageAnimation = null; + }); + } + function plainText(html) { const node = document.createElement("div"); node.innerHTML = html; @@ -652,6 +701,9 @@ elements.mobileSearchResults.replaceChildren(); setMobileMode(mobileMode === "menu" ? "content" : "menu"); }); + elements.desktopSidebarToggle.addEventListener("click", () => { + setDesktopSidebar(document.body.classList.contains("desktop-sidebar-collapsed")); + }); document.getElementById("theme-button").addEventListener("click", () => { applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"); }); diff --git a/docslib/templates/index.html b/docslib/templates/index.html index 777d62e..c49d605 100644 --- a/docslib/templates/index.html +++ b/docslib/templates/index.html @@ -22,7 +22,10 @@
-

{{DOCSLIB_TITLE}}

+
+ +

{{DOCSLIB_TITLE}}

+
diff --git a/tests/test_server.py b/tests/test_server.py index 46105fa..994f8a3 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -97,7 +97,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path): assert html_response.status_code == 200 assert "http://127.0.0.1:9000/manifest.json" in html assert "http://127.0.0.1:9000/version" in html - assert 'globalThis.COMPILED_FROM_VERSION = "0.3.9";' in html + assert 'globalThis.COMPILED_FROM_VERSION = "0.4.0";' in html assert ".content { --custom-test: yes; }" in html assert r'document.body.dataset.customTest = \"yes\";' in html assert 'id="docslib-loading"' in html @@ -177,6 +177,8 @@ def test_all_endpoints_and_self_contained_build(tmp_path): assert "--shell: #000000" in html assert 'id="desktop-search-input"' in html assert '
' in html + assert 'id="desktop-sidebar-toggle"' in html + assert 'aria-label="☰" aria-expanded="true">☰' in html assert '