From 054452bba79639e1a11e871c505545f33ca12d37 Mon Sep 17 00:00:00 2001 From: Server <10.9.8.250@reg.snw.su> Date: Sun, 9 Aug 2026 23:31:03 +0000 Subject: [PATCH] Redesign responsive documentation client --- README.md | 6 +- docslib/templates/client.css | 274 ++++++++++++--------- docslib/templates/client.js | 461 ++++++++++++++++++++++------------- docslib/templates/index.html | 81 +++--- tests/test_server.py | 18 +- 5 files changed, 513 insertions(+), 327 deletions(-) diff --git a/README.md b/README.md index df82dd9..e3536eb 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,11 @@ SharedDocsLib — небольшой Python-фреймворк для докум - `index.zip`, содержащий только `index.html`; - live-preview с перекомпиляцией изменившегося Python-модуля. -UI не содержит языко-зависимых служебных подписей: поиск, тема и мобильные меню обозначены пиктограммами, а в статусе показываются ISO-дата manifest и короткий `revision`. Светлая палитра нейтральная, тёмная использует настоящий AMOLED-чёрный. На мобильных устройствах header-кнопки открываются в отдельном правом drawer высотой `100dvh`. +UI не содержит языко-зависимых служебных подписей. Базовый шрифт — `system-ui 16px`, а UTF-8-пиктограммы используют Arial. AMOLED-тема включена по умолчанию и сохраняется локально после переключения. + +На десктопе слева постоянно видны header-кнопки и дерево страниц. Поисковый placeholder печатает первые слова случайных названий, а при вводе поле раскрывает ранжированные результаты вниз. Кнопка `⟳` вращается во время проверки manifest; при наведении она заменяет поиск и тему, показывая ISO-дату и короткий `revision`, а при нажатии синхронизирует manifest и перезагружает страницу. + +На мобильных устройствах используется viewport `100dvh` и нижний dock. Кнопка `☰` заменяет содержимое страницы копией desktop-sidebar. Ввод в нижний поиск заменяет содержимое результатами, расположенными снизу вверх по релевантности. Тема и обновление на мобильном скрыты. ## Установка из Git diff --git a/docslib/templates/client.css b/docslib/templates/client.css index 7a42610..ba280ad 100644 --- a/docslib/templates/client.css +++ b/docslib/templates/client.css @@ -1,141 +1,171 @@ :root { - --bg: #f5f5f5; - --surface: #ffffff; - --sidebar: #ededed; - --text: #171717; - --muted: #6f6f6f; - --faint: #a3a3a3; - --border: #d9d9d9; - --hover: #e3e3e3; - --accent: #171717; - --accent-soft: rgba(0, 0, 0, .08); - --code: #ebebeb; - --shadow: 0 18px 60px rgba(0, 0, 0, .16); + --shell: #ffffff; + --surface: #f2f2f2; + --elevated: #e5e5e5; + --control: #dedede; + --active: #cacaca; + --text: #111111; + --muted: #6d6d6d; + --faint: #9a9a9a; + --border: #c9c9c9; + --shadow: 0 24px 70px rgba(0, 0, 0, .16); color-scheme: light; } :root[data-theme="dark"] { - --bg: #000000; - --surface: #070707; - --sidebar: #000000; - --text: #f5f5f5; - --muted: #a3a3a3; - --faint: #666666; - --border: #242424; - --hover: #121212; - --accent: #ffffff; - --accent-soft: rgba(255, 255, 255, .10); - --code: #090909; - --shadow: 0 22px 70px rgba(0, 0, 0, .8); + --shell: #000000; + --surface: #111111; + --elevated: #222222; + --control: #242424; + --active: #686868; + --text: #f4f4f4; + --muted: #a5a5a5; + --faint: #747474; + --border: #333333; + --shadow: 0 28px 80px rgba(0, 0, 0, .72); color-scheme: dark; } * { box-sizing: border-box; } [hidden] { display: none !important; } html, body { width: 100%; height: 100dvh; min-height: 100dvh; max-height: 100dvh; overflow: hidden; } -html { background: var(--bg); } -body { margin: 0; color: var(--text); background: var(--bg); font: 15px/1.65 Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +html { background: var(--shell); } +body { margin: 0; color: var(--text); background: var(--shell); font-family: system-ui; font-size: 16px; line-height: 1.55; } button, input { font: inherit; } button, a { -webkit-tap-highlight-color: transparent; } -.app-shell { position: relative; width: 100%; height: 100%; overflow: hidden; } -.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 20; display: flex; width: 288px; height: 100%; flex-direction: column; border-right: 1px solid var(--border); background: var(--sidebar); } -.brand-row { display: flex; align-items: center; min-height: 64px; padding: 13px 16px; } -.brand-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; line-height: 1.2; } -.brand-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; } -.sidebar-actions { display: flex; gap: 7px; padding: 5px 12px 13px; border-bottom: 1px solid var(--border); } -.action-button, .icon-button, .key-button { border: 0; color: var(--muted); background: transparent; cursor: pointer; } -.action-button { display: grid; width: 36px; height: 36px; flex: 0 0 auto; place-items: center; padding: 0; border-radius: 7px; } -.action-button:hover, .icon-button:hover, .key-button:hover { color: var(--text); background: var(--hover); } -.icon-button { display: grid; width: 36px; height: 36px; flex: 0 0 auto; place-items: center; border-radius: 7px; } -.page-tree { flex: 1; overflow: auto; padding: 12px 9px 24px; scrollbar-width: thin; } -.tree-group { margin: 1px 0; } -.tree-children { margin-left: 12px; padding-left: 8px; border-left: 1px solid var(--border); } -.tree-link { display: flex; width: 100%; min-height: 33px; align-items: center; gap: 7px; padding: 5px 9px; border: 0; border-radius: 6px; color: var(--muted); background: transparent; cursor: pointer; text-align: left; } -.tree-link:hover { color: var(--text); background: var(--hover); } -.tree-link.active { color: var(--text); background: var(--accent-soft); font-weight: 600; } -.tree-number { min-width: 26px; color: var(--faint); font: 10px ui-monospace, monospace; } -.tree-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.update-status { display: flex; min-height: 48px; align-items: center; gap: 9px; padding: 0 17px; border-top: 1px solid var(--border); color: var(--muted); font-size: 11px; } -.update-status i { width: 7px; height: 7px; border-radius: 50%; background: #55a96b; box-shadow: 0 0 0 3px rgba(85, 169, 107, .11); } -.update-status.syncing i { background: #d8a441; animation: pulse 1s infinite; } -.update-status.error i { background: #d75b5b; } -@keyframes pulse { 50% { opacity: .35; } } -.workspace { height: 100%; min-height: 0; margin-left: 288px; overflow-x: hidden; overflow-y: auto; overscroll-behavior-y: contain; scroll-behavior: smooth; -webkit-overflow-scrolling: touch; } -.topbar { position: sticky; top: 0; z-index: 10; display: flex; min-height: 58px; align-items: center; gap: 10px; padding: 0 30px; border-bottom: 1px solid color-mix(in srgb, var(--border), transparent 18%); background: color-mix(in srgb, var(--bg), transparent 7%); backdrop-filter: blur(16px); } -.breadcrumbs { min-width: 0; flex: 1; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } -.breadcrumbs span + span::before { margin: 0 8px; color: var(--faint); content: "/"; } -.header-actions { display: flex; max-width: 55%; align-items: center; } -.header-buttons { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; } -.header-buttons::-webkit-scrollbar { display: none; } -.header-link { padding: 5px 10px; border: 1px solid var(--border); border-radius: 7px; color: var(--muted); text-decoration: none; font-size: 12px; } -.header-link:hover { color: var(--text); border-color: var(--faint); background: var(--surface); } -.header-link.active { color: var(--accent); border-color: color-mix(in srgb, var(--accent), transparent 45%); background: var(--accent-soft); } -.content { width: min(820px, calc(100% - 64px)); min-height: calc(100dvh - 180px); margin: 0 auto; padding: 62px 0 70px; outline: none; } -.content > :first-child { margin-top: 0; } -.content h1 { margin: 0 0 1.4rem; font-size: clamp(2rem, 5vw, 3.1rem); line-height: 1.1; letter-spacing: -.04em; } -.content h2 { margin: 2.4rem 0 .75rem; padding-bottom: .35rem; border-bottom: 1px solid var(--border); font-size: 1.55rem; letter-spacing: -.02em; } -.content h3 { margin: 1.8rem 0 .5rem; font-size: 1.15rem; } -.content p { margin: .7rem 0 1rem; } -.content a { color: var(--accent); text-underline-offset: 3px; } -.content img { display: block; max-width: 100%; height: auto; margin: 1.35rem auto; border-radius: 8px; box-shadow: 0 7px 25px rgba(0, 0, 0, .12); } -.content figure { margin: 1.5rem 0; } -.content figure img { margin-bottom: .55rem; } -.content figcaption { color: var(--muted); text-align: center; font-size: 12px; } -.content pre { overflow: auto; margin: 1.2rem 0; padding: 17px 19px; border: 1px solid var(--border); border-radius: 9px; background: var(--code); line-height: 1.55; } -.content code { padding: .12em .32em; border-radius: 4px; background: var(--code); font: .88em/1.5 "SFMono-Regular", Consolas, monospace; } -.content pre code { padding: 0; background: transparent; } -.content blockquote { margin: 1.3rem 0; padding: .3rem 1.2rem; border-left: 3px solid var(--accent); color: var(--muted); } -.content blockquote footer { font-size: 12px; } -.content hr { margin: 2.5rem 0; border: 0; border-top: 1px solid var(--border); } -.content details { margin: 1rem 0; padding: .8rem 1rem; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); } -.content summary { cursor: pointer; font-weight: 650; } -.callout { margin: 1.25rem 0; padding: 13px 16px; border: 1px solid color-mix(in srgb, var(--accent), transparent 68%); border-left: 4px solid var(--accent); border-radius: 7px; background: var(--accent-soft); } -.callout strong { display: block; margin-bottom: 3px; } -.badge { display: inline-block; padding: 1px 7px; border-radius: 999px; color: var(--accent); background: var(--accent-soft); font-size: .8em; font-weight: 700; } -.table-wrap { overflow-x: auto; margin: 1.2rem 0; } -.content table { width: 100%; border-collapse: collapse; } -.content th, .content td { padding: 9px 12px; border: 1px solid var(--border); text-align: left; } -.content th { background: var(--sidebar); font-size: 12px; } -.page-navigation { display: grid; width: min(820px, calc(100% - 64px)); grid-template-columns: 1fr 1fr; gap: 14px; margin: 0 auto; padding: 0 0 58px; } -.nav-card { min-height: 66px; padding: 11px 15px; border: 1px solid var(--border); border-radius: 9px; color: var(--muted); background: var(--surface); cursor: pointer; text-align: left; } -.nav-card:last-child { text-align: right; } -.nav-card:hover:not(:disabled) { color: var(--text); border-color: var(--accent); transform: translateY(-1px); } -.nav-card:disabled { visibility: hidden; } -.nav-card small { display: block; margin-bottom: 2px; color: var(--faint); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } -.search-dialog { width: min(650px, calc(100% - 28px)); max-height: min(650px, calc(100dvh - 40px)); margin-top: 9dvh; padding: 0; border: 1px solid var(--border); border-radius: 12px; color: var(--text); background: var(--surface); box-shadow: var(--shadow); } -.search-dialog::backdrop { background: rgba(0, 0, 0, .38); backdrop-filter: blur(2px); } -.search-box { display: flex; align-items: center; gap: 9px; padding: 11px; border-bottom: 1px solid var(--border); } -.search-box label { display: flex; flex: 1; align-items: center; gap: 9px; } -.search-box input { width: 100%; border: 0; outline: 0; color: var(--text); background: transparent; } -.key-button { padding: 4px 7px; border: 1px solid var(--border); border-radius: 5px; font-size: 10px; } -.search-results { max-height: 480px; overflow: auto; padding: 7px; } -.search-result { display: block; width: 100%; padding: 10px 12px; border: 0; border-radius: 7px; color: var(--text); background: transparent; cursor: pointer; text-align: left; } -.search-result:hover, .search-result:focus { outline: 0; background: var(--hover); } -.search-result strong { display: block; } -.search-result span { display: block; overflow: hidden; margin-top: 3px; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } -.empty-state { padding: 48px 20px; color: var(--muted); text-align: center; } -.sidebar-overlay, .actions-overlay, .mobile-only { display: none; } +button { color: inherit; } +.glyph-button, .refresh-glyph, .nav-arrow { font-family: Arial, sans-serif; } -@media (max-width: 800px) { - .mobile-only { display: grid; } - .sidebar { transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow); } - body.sidebar-open .sidebar { transform: none; } - .sidebar-overlay, .actions-overlay { position: fixed; inset: 0; z-index: 15; background: rgba(0, 0, 0, .58); } - body.sidebar-open .sidebar-overlay { display: block; } - .header-actions { position: fixed; inset: 0 0 0 auto; z-index: 30; display: flex; width: min(88vw, 420px); max-width: none; height: 100dvh; flex-direction: column; align-items: stretch; padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) 12px; border-left: 1px solid var(--border); background: var(--sidebar); box-shadow: var(--shadow); transform: translateX(102%); transition: transform .22s ease; } - body.actions-open .header-actions { transform: none; } - body.actions-open .actions-overlay { display: block; } - .actions-close { align-self: flex-end; margin-bottom: 8px; } - .header-buttons { flex: 1; flex-direction: column; align-items: stretch; gap: 6px; overflow-y: auto; } - .header-link { width: 100%; padding: 11px 13px; border-color: transparent; font-size: 14px; } - .workspace { margin-left: 0; } - .topbar { padding: 0 14px; } - .content, .page-navigation { width: min(100% - 34px, 820px); } - .content { padding-top: 42px; } +.app-shell { display: grid; width: 100%; height: 100%; grid-template-columns: clamp(280px, 20vw, 370px) minmax(0, 1fr); overflow: hidden; background: var(--shell); } +.desktop-sidebar { display: flex; min-width: 0; height: 100%; flex-direction: column; overflow: hidden; padding: 40px 34px 26px; background: var(--shell); } +.site-title { overflow: hidden; flex: 0 0 auto; margin: 0 0 30px; font-size: clamp(1.8rem, 2.35vw, 3rem); font-weight: 400; line-height: 1.08; letter-spacing: -.035em; text-overflow: ellipsis; white-space: nowrap; } +.sidebar-header-buttons { display: flex; flex: 0 0 auto; flex-direction: column; gap: 6px; margin-bottom: 42px; } +.header-link { display: block; width: 100%; padding: 8px 15px; border: 1px solid transparent; border-radius: 24px; color: var(--text); background: transparent; text-decoration: none; line-height: 1.25; transition: background .16s ease, border-color .16s ease; } +.header-link:hover { background: var(--control); } +.header-link.active { color: var(--text); background: var(--active); } +.header-link:not(.active):not(:hover) { border-color: var(--border); } +.sidebar-header-buttons .header-link:not(.active):first-child { border-color: transparent; } + +.page-tree { min-height: 0; overflow: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; } +.desktop-sidebar > .page-tree { flex: 1; padding: 0 2px 34px; } +.tree-group { margin: 2px 0; } +.tree-children { margin-left: 19px; padding-left: 12px; border-left: 1px solid var(--muted); } +.tree-link { display: flex; width: 100%; min-height: 36px; align-items: baseline; gap: 5px; padding: 6px 13px; border: 0; border-radius: 22px; color: var(--text); background: transparent; cursor: pointer; text-align: left; transition: background .15s ease; } +.tree-link:hover { background: var(--control); } +.tree-link.active { background: var(--active); } +.tree-number { flex: 0 0 auto; color: var(--muted); } +.tree-title { min-width: 0; overflow-wrap: anywhere; } + +.workspace { display: flex; min-width: 0; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; } +.desktop-toolbar { position: relative; z-index: 20; display: flex; min-height: 128px; flex: 0 0 128px; align-items: center; justify-content: flex-end; gap: 18px; padding: 24px 28px; background: var(--shell); } +.desktop-search-shell { position: relative; width: min(660px, 52vw); max-width: min(660px, 52vw); flex: 0 1 min(660px, 52vw); transition: max-width .24s ease, opacity .16s ease, transform .24s ease; } +.search-control { display: flex; width: 100%; height: 64px; align-items: center; border-radius: 34px; background: var(--control); } +.search-control input, .mobile-search-control input { min-width: 0; flex: 1; height: 100%; padding: 0 10px 0 28px; border: 0; outline: 0; color: var(--text); background: transparent; appearance: none; } +.search-control input::placeholder, .mobile-search-control input::placeholder { color: var(--faint); opacity: 1; } +.search-control input::-webkit-search-cancel-button, .mobile-search-control input::-webkit-search-cancel-button { display: none; } +.glyph-button { display: grid; place-items: center; border: 0; cursor: pointer; } +.search-glyph { width: 64px; height: 64px; flex: 0 0 64px; border-radius: 50%; color: var(--text); background: color-mix(in srgb, var(--control), var(--text) 8%); font-size: 2rem; } +.desktop-search-results { position: absolute; inset: 74px 0 auto; display: flex; max-height: min(540px, calc(100dvh - 154px)); flex-direction: column; gap: 4px; overflow: auto; padding: 10px; border-radius: 28px; background: var(--elevated); box-shadow: var(--shadow); opacity: 0; pointer-events: none; transform: translateY(-10px) scale(.985); transform-origin: top; transition: opacity .16s ease, transform .18s ease; } +.desktop-search-shell.open .desktop-search-results { opacity: 1; pointer-events: auto; transform: none; } +.search-result { display: block; width: 100%; padding: 13px 18px; border: 0; border-radius: 22px; color: var(--text); background: transparent; cursor: pointer; text-align: left; } +.search-result:hover, .search-result:focus-visible { outline: 0; background: var(--active); } +.search-result strong { display: block; overflow: hidden; font-size: 1.08rem; font-weight: 550; text-overflow: ellipsis; white-space: nowrap; } +.search-result span { display: block; overflow: hidden; margin-top: 1px; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } + +.theme-button { width: 64px; height: 64px; max-width: 64px; flex: 0 0 64px; border-radius: 50%; color: var(--text); background: var(--control); font-size: 1.8rem; transition: max-width .24s ease, opacity .16s ease, transform .24s ease; } +.theme-button:hover { background: var(--active); } +.refresh-button { display: flex; width: 64px; max-width: min(430px, 44vw); height: 64px; flex: 0 0 auto; align-items: center; justify-content: flex-start; gap: 14px; overflow: hidden; padding: 0; border: 0; border-radius: 34px; color: var(--text); background: var(--control); cursor: pointer; transition: width .26s ease, background .16s ease; } +.refresh-button:hover { background: var(--active); } +.refresh-glyph { display: grid; width: 64px; height: 64px; flex: 0 0 64px; place-items: center; font-size: 2rem; } +.refresh-button.loading .refresh-glyph { animation: refresh-spin .8s linear infinite; } +.manifest-identity { overflow: hidden; padding-right: 24px; color: var(--text); font-size: .82rem; white-space: nowrap; opacity: 0; transition: opacity .12s ease .03s; } +.desktop-toolbar.refresh-expanded .desktop-search-shell { max-width: 0; opacity: 0; pointer-events: none; transform: translateX(18px); } +.desktop-toolbar.refresh-expanded .theme-button { max-width: 0; opacity: 0; pointer-events: none; transform: scale(.72); } +.desktop-toolbar.refresh-expanded .refresh-button { width: min(430px, 64vw); } +.desktop-toolbar.refresh-expanded .manifest-identity { opacity: 1; } +@keyframes refresh-spin { to { transform: rotate(360deg); } } + +.page-surface { min-height: 0; flex: 1; overflow-x: hidden; overflow-y: auto; overscroll-behavior-y: contain; border-radius: 54px 0 0; background: var(--surface); scroll-behavior: smooth; -webkit-overflow-scrolling: touch; } +.content { width: min(1120px, calc(100% - 120px)); min-height: calc(100dvh - 330px); margin: 0 auto; padding: 68px 0 64px; outline: none; } +.content > :first-child { margin-top: 0; } +.content h1 { margin: 0 0 2rem; font-size: clamp(2.35rem, 3.1vw, 4rem); font-weight: 400; line-height: 1.08; letter-spacing: -.035em; } +.content h2 { margin: 2.5rem 0 .9rem; padding-bottom: .35rem; border-bottom: 1px solid var(--border); font-size: 1.65rem; font-weight: 500; } +.content h3 { margin: 1.9rem 0 .55rem; font-size: 1.25rem; font-weight: 550; } +.content p { margin: .8rem 0 1.2rem; font-size: 1.08rem; } +.content a { color: var(--text); text-underline-offset: 4px; } +.content img { display: block; max-width: 100%; height: auto; margin: 1.5rem auto; border-radius: 20px; } +.content figure { margin: 1.6rem 0; } +.content figcaption { margin-top: .55rem; color: var(--muted); text-align: center; } +.content pre { overflow: auto; margin: 1.3rem 0; padding: 18px 20px; border: 1px solid var(--border); border-radius: 18px; background: var(--shell); } +.content code { padding: .12em .35em; border-radius: 5px; background: var(--shell); font: .88em/1.5 ui-monospace, monospace; } +.content pre code { padding: 0; background: transparent; } +.content blockquote { margin: 1.4rem 0; padding: .35rem 1.25rem; border-left: 2px solid var(--text); color: var(--muted); } +.content hr { margin: 2.6rem 0; border: 0; border-top: 1px solid var(--border); } +.content details { margin: 1rem 0; padding: 1rem 1.1rem; border: 1px solid var(--border); border-radius: 16px; background: var(--shell); } +.content summary { cursor: pointer; font-weight: 600; } +.callout { margin: 1.3rem 0; padding: 14px 17px; border: 1px solid var(--border); border-left: 4px solid var(--text); border-radius: 14px; background: var(--shell); } +.callout strong { display: block; margin-bottom: 3px; } +.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; color: var(--shell); background: var(--text); font-size: .8em; font-weight: 700; } +.table-wrap { overflow-x: auto; margin: 1.3rem 0; } +.content table { width: 100%; border-collapse: collapse; } +.content th, .content td { padding: 10px 13px; border: 1px solid var(--border); text-align: left; } +.content th { background: var(--control); } +.page-navigation { display: flex; width: min(1120px, calc(100% - 120px)); gap: 14px; margin: 0 auto; padding: 0 0 64px; } +.nav-card { display: flex; min-height: 56px; align-items: center; gap: 10px; padding: 9px 20px; border: 0; border-radius: 30px; color: var(--text); background: var(--control); cursor: pointer; } +.nav-card:hover:not(:disabled) { background: var(--active); } +.nav-card:disabled { display: none; } +.nav-card small { color: inherit; font-size: 1.3rem; } +#next-page { color: var(--shell); background: var(--text); } +:root[data-theme="dark"] #next-page { color: #000; background: #eee; } + +.mobile-view, .mobile-dock { display: none; } + +@media (max-width: 820px) { + .app-shell { display: block; } + .desktop-sidebar, .desktop-toolbar { display: none; } + .workspace { width: 100%; height: 100%; } + .page-surface { display: block; width: 100%; border-radius: 0; background: var(--shell); } + .content { width: 100%; min-height: 0; padding: 52px 28px 28px; } + .content h1 { margin-bottom: 1.55rem; font-size: clamp(2.45rem, 12vw, 4rem); } + .content h2 { font-size: 1.55rem; } + .content p { font-size: 1.05rem; } + .page-navigation { width: 100%; flex-direction: column; align-items: flex-start; gap: 10px; padding: 0 28px calc(150px + env(safe-area-inset-bottom)); } + #next-page { order: 1; } + #previous-page { order: 2; } + .nav-card { max-width: calc(100vw - 56px); min-height: 54px; } + .mobile-view { display: none; min-height: 0; flex: 1; overflow: auto; background: var(--shell); } + body.mobile-menu-open .page-surface, body.mobile-search-open .page-surface { display: none; } + 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)); } + .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; } + .mobile-header-buttons .header-link { padding: 12px 18px; border-radius: 28px; font-size: 1.05rem; } + .mobile-page-tree { overflow: visible; padding-bottom: 24px; } + .mobile-page-tree .tree-link { min-height: 46px; padding: 9px 14px; font-size: 1.05rem; } + .mobile-page-tree .tree-children { margin-left: 20px; padding-left: 12px; } + .mobile-search-view { flex-direction: column; justify-content: flex-end; padding: 40px 28px calc(142px + env(safe-area-inset-bottom)); overflow: hidden; } + .mobile-search-results { display: flex; max-height: 100%; flex: 1; flex-direction: column-reverse; justify-content: flex-start; gap: 20px; overflow-y: auto; } + .mobile-search-results .search-result { flex: 0 0 auto; padding: 8px 0; border-radius: 0; } + .mobile-search-results .search-result:hover, .mobile-search-results .search-result:focus-visible { background: transparent; } + .mobile-search-results .search-result strong { font-size: 1.25rem; } + .mobile-search-results .search-result span { font-size: 1rem; } + .mobile-dock { position: fixed; inset: auto 0 0; z-index: 50; display: flex; align-items: center; gap: 18px; padding: 16px 28px max(22px, env(safe-area-inset-bottom)); background: linear-gradient(0deg, var(--shell) 74%, transparent); } + .mobile-menu-button { width: 72px; height: 72px; flex: 0 0 72px; border-radius: 50%; color: var(--text); background: var(--control); font-size: 2rem; } + body.mobile-menu-open .mobile-menu-button { background: var(--active); } + .mobile-search-control { display: flex; min-width: 0; height: 72px; flex: 1; align-items: center; border-radius: 38px; background: var(--control); } + .mobile-search-control input { padding-left: 22px; font-size: 1rem; } + .mobile-search-control .search-glyph { width: 64px; height: 64px; flex-basis: 64px; background: transparent; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } } @media print { - .sidebar, .topbar, .page-navigation { display: none; } - .workspace { margin: 0; } + html, body { height: auto; max-height: none; overflow: visible; } + .app-shell { display: block; } + .desktop-sidebar, .desktop-toolbar, .mobile-dock, .page-navigation { display: none; } + .workspace, .page-surface { display: block; height: auto; overflow: visible; } .content { width: 100%; padding: 0; } } diff --git a/docslib/templates/client.js b/docslib/templates/client.js index 346810d..8862250 100644 --- a/docslib/templates/client.js +++ b/docslib/templates/client.js @@ -3,30 +3,47 @@ const embedded = JSON.parse(document.getElementById("docslib-manifest").textContent); const config = JSON.parse(document.getElementById("docslib-config").textContent); + const mobileLayout = matchMedia("(max-width: 820px)"); + const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); const elements = { - title: document.getElementById("site-title"), - tree: document.getElementById("page-tree"), + desktopTitle: document.getElementById("desktop-site-title"), + mobileTitle: document.getElementById("mobile-site-title"), + desktopHeaderButtons: document.getElementById("desktop-header-buttons"), + mobileHeaderButtons: document.getElementById("mobile-header-buttons"), + desktopTree: document.getElementById("desktop-page-tree"), + mobileTree: document.getElementById("mobile-page-tree"), + toolbar: document.getElementById("desktop-toolbar"), + desktopSearchShell: document.getElementById("desktop-search-shell"), + desktopSearchInput: document.getElementById("desktop-search-input"), + desktopSearchButton: document.getElementById("desktop-search-button"), + desktopSearchResults: document.getElementById("desktop-search-results"), + mobileSearchInput: document.getElementById("mobile-search-input"), + mobileSearchButton: document.getElementById("mobile-search-button"), + mobileSearchResults: document.getElementById("mobile-search-results"), content: document.getElementById("content"), - breadcrumbs: document.getElementById("breadcrumbs"), + pageSurface: document.getElementById("page-surface"), previous: document.getElementById("previous-page"), next: document.getElementById("next-page"), - status: document.getElementById("update-status"), - searchDialog: document.getElementById("search-dialog"), - searchInput: document.getElementById("search-input"), - searchResults: document.getElementById("search-results"), themeIcon: document.getElementById("theme-icon"), - headerButtons: document.getElementById("header-buttons"), - headerActions: document.getElementById("header-actions"), - headerActionsAnchor: document.getElementById("header-actions-anchor"), - openActions: document.getElementById("open-actions"), - workspace: document.querySelector(".workspace"), + refresh: document.getElementById("refresh-button"), + identity: document.getElementById("manifest-identity"), + mobileMenuButton: document.getElementById("mobile-menu-button"), }; + let manifest = embedded; let currentNumber = null; - const mobileLayout = matchMedia("(max-width: 800px)"); + let syncing = false; + let mobileMode = "content"; + let modeBeforeSearch = "content"; + let placeholderTimer = 0; + let placeholderText = ""; + let placeholderTarget = ""; + let placeholderIndex = 0; + let placeholderPhase = "typing"; + let lastPlaceholderPage = -1; const dbPromise = new Promise((resolve, reject) => { - if (!window.indexedDB) return reject(new Error("IndexedDB unavailable")); + if (!window.indexedDB) return reject(new Error("IndexedDB")); const request = indexedDB.open("docslib-cache", 1); request.onupgradeneeded = () => request.result.createObjectStore("manifests"); request.onsuccess = () => resolve(request.result); @@ -61,10 +78,14 @@ return [updated, revision].filter(Boolean).join(" · ") || "—"; } - function setStatus(state = "", detail = "") { - elements.status.className = `update-status ${state}`.trim(); - elements.status.querySelector("span").textContent = manifestIdentity(); - elements.status.title = detail || String(manifest.revision || ""); + function updateIdentity() { + elements.identity.textContent = manifestIdentity(); + elements.refresh.title = String(manifest.revision || ""); + } + + function setSyncing(value) { + syncing = value; + elements.refresh.classList.toggle("loading", value); } function routeNumber() { @@ -72,28 +93,36 @@ return match ? decodeURIComponent(match[1]) : null; } + function isPlainClick(event) { + return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey; + } + + function setMobileMode(mode) { + mobileMode = mode; + document.body.classList.toggle("mobile-menu-open", mode === "menu"); + document.body.classList.toggle("mobile-search-open", mode === "search"); + } + + function closeSearch({ clear = false } = {}) { + elements.desktopSearchShell.classList.remove("open"); + if (clear) { + elements.desktopSearchInput.value = ""; + elements.mobileSearchInput.value = ""; + } + if (mobileMode === "search") setMobileMode(modeBeforeSearch); + } + 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); - document.body.classList.remove("sidebar-open"); - document.body.classList.remove("actions-open"); } - function placeHeaderActions() { - if (mobileLayout.matches) { - if (elements.headerActions.parentElement !== document.body) document.body.append(elements.headerActions); - } else { - if (elements.headerActions.previousElementSibling !== elements.headerActionsAnchor) { - elements.headerActionsAnchor.after(elements.headerActions); - } - document.body.classList.remove("actions-open"); - } - } - - function makeTree() { - elements.tree.replaceChildren(); + function buildTree(container) { + container.replaceChildren(); const root = { children: new Map(), page: null }; manifest.pages.forEach((page) => { let node = root; @@ -113,7 +142,7 @@ button.dataset.page = child.page.number; const number = document.createElement("span"); number.className = "tree-number"; - number.textContent = child.page.number; + number.textContent = `${child.page.number}.`; const title = document.createElement("span"); title.className = "tree-title"; title.textContent = child.page.title; @@ -130,7 +159,34 @@ parent.append(group); }); }; - appendNodes(elements.tree, root); + appendNodes(container, root); + } + + function buildHeaderButtons(container) { + container.replaceChildren(); + const buttons = (manifest.ui && manifest.ui.header_buttons) || []; + buttons.forEach((button) => { + if (!button.label || (!button.href && !button.page)) return; + const link = document.createElement("a"); + link.className = "header-link"; + link.textContent = button.label; + if (button.page) { + link.href = `#/page/${encodeURIComponent(button.page)}`; + link.dataset.page = button.page; + link.addEventListener("click", (event) => { + if (!isPlainClick(event)) return; + event.preventDefault(); + navigate(button.page); + }); + } else { + link.href = button.href; + if (button.target === "_blank") { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } + } + container.append(link); + }); } function assetDataUri(name) { @@ -143,13 +199,34 @@ const name = element.dataset.docslibAsset; const uri = assetDataUri(name); if (uri && element.tagName === "IMG") element.src = uri; - else if (!uri) { - element.setAttribute("title", name); - element.classList.add("missing-asset"); - } + else if (!uri) element.setAttribute("title", name); }); } + function updateActiveNavigation(number) { + document.querySelectorAll(".tree-link[data-page]").forEach((button) => { + button.classList.toggle("active", button.dataset.page === number); + }); + document.querySelectorAll(".header-link[data-page]").forEach((button) => { + const target = button.dataset.page; + button.classList.toggle("active", number === target || number.startsWith(`${target}.`)); + }); + } + + function setupNavButton(button, page, direction) { + button.disabled = !page; + button.replaceChildren(); + if (!page) return; + const arrow = document.createElement("small"); + arrow.className = "nav-arrow"; + arrow.textContent = direction; + const title = document.createElement("span"); + title.textContent = page.title; + if (direction === "←") button.append(arrow, title); + else button.append(title, arrow); + button.onclick = () => navigate(page.number); + } + function renderPage(number) { if (!manifest.pages.length) { elements.content.replaceChildren(); @@ -166,80 +243,15 @@ hydrateAssets(); elements.content.querySelectorAll("a[href^='#/page/']").forEach((link) => { link.addEventListener("click", (event) => { - if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if (!isPlainClick(event)) return; event.preventDefault(); navigate(decodeURIComponent(link.hash.slice(7))); }); }); - elements.tree.querySelectorAll(".tree-link").forEach((button) => button.classList.toggle("active", button.dataset.page === page.number)); - elements.headerButtons.querySelectorAll("[data-page]").forEach((button) => { - const target = button.dataset.page; - button.classList.toggle("active", page.number === target || page.number.startsWith(`${target}.`)); - }); - elements.breadcrumbs.replaceChildren(); - const numberParts = page.number.split("."); - numberParts.forEach((_, partIndex) => { - const candidate = manifest.pages.find((item) => item.number === numberParts.slice(0, partIndex + 1).join(".")); - if (candidate) { - const crumb = document.createElement("span"); - crumb.textContent = candidate.title; - elements.breadcrumbs.append(crumb); - } - }); + updateActiveNavigation(page.number); setupNavButton(elements.previous, manifest.pages[index - 1], "←"); setupNavButton(elements.next, manifest.pages[index + 1], "→"); - elements.content.focus({ preventScroll: true }); - elements.workspace.scrollTo({ top: 0, behavior: "instant" }); - } - - function setupNavButton(button, page, direction) { - button.disabled = !page; - button.replaceChildren(); - if (!page) return; - const small = document.createElement("small"); - small.textContent = direction; - const title = document.createElement("span"); - title.textContent = page.title; - button.append(small, title); - button.onclick = () => navigate(page.number); - } - - function renderHeaderButtons() { - elements.headerButtons.replaceChildren(); - const buttons = (manifest.ui && manifest.ui.header_buttons) || []; - buttons.forEach((button) => { - if (!button.label || (!button.href && !button.page)) return; - const link = document.createElement("a"); - link.className = "header-link"; - link.textContent = button.label; - if (button.page) { - link.href = `#/page/${encodeURIComponent(button.page)}`; - link.dataset.page = button.page; - link.addEventListener("click", (event) => { - if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - event.preventDefault(); - navigate(button.page); - }); - } else { - link.href = button.href; - } - if (!button.page && button.target === "_blank") { - link.target = "_blank"; - link.rel = "noopener noreferrer"; - } - link.addEventListener("click", () => document.body.classList.remove("actions-open")); - elements.headerButtons.append(link); - }); - elements.openActions.hidden = buttons.length === 0; - } - - function renderAll() { - document.documentElement.lang = "und"; - elements.title.textContent = manifest.title; - makeTree(); - renderHeaderButtons(); - renderPage(routeNumber() || currentNumber || (manifest.pages[0] && manifest.pages[0].number)); - setStatus(); + elements.pageSurface.scrollTo({ top: 0, behavior: "instant" }); } function plainText(html) { @@ -248,34 +260,124 @@ return (node.textContent || "").replace(/\s+/g, " ").trim(); } - function search(query) { + function rankedPages(query) { const needle = query.trim().toLocaleLowerCase(); - const matches = !needle ? manifest.pages : manifest.pages.filter((page) => - `${page.number} ${page.title} ${plainText(page.content)}`.toLocaleLowerCase().includes(needle) - ); - elements.searchResults.replaceChildren(); - matches.slice(0, 30).forEach((page) => { + if (!needle) return []; + const tokens = needle.split(/\s+/).filter(Boolean); + return manifest.pages + .map((page, order) => { + const title = page.title.toLocaleLowerCase(); + const number = page.number.toLocaleLowerCase(); + const text = plainText(page.content); + const haystack = `${number} ${title} ${text.toLocaleLowerCase()}`; + let score = 0; + if (title === needle) score += 1000; + if (title.startsWith(needle)) score += 500; + if (title.includes(needle)) score += 300; + if (number === needle) score += 600; + if (haystack.includes(needle)) score += 120; + tokens.forEach((token) => { + if (title.startsWith(token)) score += 80; + else if (title.includes(token)) score += 45; + else if (haystack.includes(token)) score += 12; + }); + return { page, text, score, order }; + }) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || a.order - b.order); + } + + function renderSearchResults(container, query, limit) { + container.replaceChildren(); + rankedPages(query).slice(0, limit).forEach(({ page, text }) => { const button = document.createElement("button"); button.className = "search-result"; const title = document.createElement("strong"); - title.textContent = `${page.number} ${page.title}`; + title.textContent = `${page.number}. ${page.title}`; const excerpt = document.createElement("span"); - excerpt.textContent = plainText(page.content).slice(0, 150) || "—"; + excerpt.textContent = text.slice(0, 110) || "—"; button.append(title, excerpt); - button.addEventListener("click", () => { - elements.searchDialog.close(); - navigate(page.number); - }); - elements.searchResults.append(button); + button.addEventListener("click", () => navigate(page.number)); + container.append(button); }); - if (!matches.length) elements.searchResults.innerHTML = '